перепиши код чтобы при сканирование чека реально р...
बनाया गया: 10 दिसंबर 2025
का उपयोग करके उत्तर दिया गया GPT-5.1 Thinking द्वारा Chat01
बनाया गया: 10 दिसंबर 2025
का उपयोग करके उत्तर दिया गया GPT-5.1 Thinking द्वारा Chat01
перепиши код чтобы при сканирование чека реально распознавал сумму с чека а не только сумму 1235 исправь добавивь искусственный интеллект отправь полный исправленный код import React, { useEffect, useState } from 'react';
import {
SafeAreaView,
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
FlatList,
StatusBar,
KeyboardAvoidingView,
Platform,
Switch,
Alert,
Image,
ActivityIndicator,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as ImagePicker from 'expo-image-picker';
import * as Speech from 'expo-speech';
// ---------- константы ----------
const STORAGE_KEYS = {
USERS: 'FINAPP_USERS',
SESSION: 'FINAPP_SESSION',
};
const DARK_THEME = {
mode: 'dark',
background: '#020617',
card: '#020617',
cardAlt: '#0B1220',
accent: '#22C55E',
accentSoft: 'rgba(34,197,94,0.22)',
text: '#F9FAFB',
textMuted: '#9CA3AF',
border: '#1F2937',
danger: '#EF4444',
success: '#22C55E',
warning: '#EAB308',
};
const LIGHT_THEME = {
mode: 'light',
background: '#F3F4F6',
card: '#FFFFFF',
cardAlt: '#E5E7EB',
accent: '#0F766E',
accentSoft: 'rgba(15,118,110,0.16)',
text: '#111827',
textMuted: '#6B7280',
border: '#D1D5DB',
danger: '#DC2626',
success: '#16A34A',
warning: '#CA8A04',
};
const DEFAULT_CURRENCY = '₸';
const DEFAULT_CATEGORIES = [
'Еда',
'Транспорт',
'Жильё',
'Здоровье',
'Подписки',
'Игры/развлечения',
'Покупки',
'Сбережения',
'Другое',
];
// ---------- стили ----------
const base = StyleSheet.create({
flex1: { flex: 1 },
center: { justifyContent: 'center', alignItems: 'center' },
container: { flex: 1 },
authContainer: { flex: 1, paddingHorizontal: 24, paddingTop: 32, paddingBottom: 24 },
title: { fontSize: 28, fontWeight: '700', marginBottom: 4 },
subtitle: { fontSize: 14, marginBottom: 24 },
input: {
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 14,
marginBottom: 12,
},
button: {
borderRadius: 18,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
marginTop: 4,
},
buttonText: { fontSize: 15, fontWeight: '600' },
link: { fontSize: 14, marginTop: 12, textAlign: 'center' },
errorText: { fontSize: 13, marginBottom: 8 },
header: {
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerTitle: { fontSize: 22, fontWeight: '700' },
headerSubtitle: { fontSize: 12 },
chipRow: { flexDirection: 'row', alignItems: 'center' },
chip: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
marginLeft: 8,
},
chipText: { fontSize: 12 },
content: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 4,
},
card: {
borderRadius: 20,
padding: 16,
marginBottom: 14,
shadowColor: '#000',
shadowOpacity: 0.14,
shadowRadius: 12,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
cardHeaderRow: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 6,
},
cardTitle: { fontSize: 16, fontWeight: '600' },
cardSubtitle: { fontSize: 12 },
row: { flexDirection: 'row', alignItems: 'center' },
rowBetween: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
pill: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
marginLeft: 6,
},
pillText: { fontSize: 11, fontWeight: '500' },
tabBar: {
flexDirection: 'row',
paddingHorizontal: 6,
paddingVertical: 8,
borderTopWidth: StyleSheet.hairlineWidth,
},
tabButton: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
borderRadius: 999,
marginHorizontal: 4,
},
tabLabel: { fontSize: 11, marginTop: 2 },
badgeDot: {
width: 8,
height: 8,
borderRadius: 999,
marginRight: 4,
},
smallText: { fontSize: 12 },
tinyText: { fontSize: 11 },
sectionTitle: { fontSize: 14, fontWeight: '600', marginBottom: 8 },
inputRow: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 14,
paddingHorizontal: 10,
marginBottom: 8,
},
inputInline: { flex: 1, paddingVertical: 6, fontSize: 13 },
expenseItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 10,
},
divider: {
height: StyleSheet.hairlineWidth,
marginVertical: 6,
},
tag: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
marginRight: 6,
marginTop: 4,
},
tagText: { fontSize: 11 },
chartRow: { marginTop: 6 },
chartBarBg: {
height: 10,
borderRadius: 999,
overflow: 'hidden',
},
chartBarFill: {
height: 10,
borderRadius: 999,
},
chatBubble: {
padding: 10,
borderRadius: 14,
marginVertical: 4,
maxWidth: '85%',
},
chatUser: { alignSelf: 'flex-end' },
chatAi: { alignSelf: 'flex-start' },
badge: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 999,
marginRight: 6,
marginBottom: 6,
},
avatarCircle: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
avatarImage: {
width: 44,
height: 44,
borderRadius: 22,
},
goalImage: {
width: '100%',
height: 120,
borderRadius: 12,
marginTop: 6,
},
});
// ---------- helpers ----------
const formatCurrency = (amount, currency = DEFAULT_CURRENCY) => {
const n = Number.isFinite(amount) ? amount : 0;
const rounded = Math.round(n);
return ${currency} ${rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')};
};
const formatDate = (isoString) => {
if (!isoString) return '';
const d = new Date(isoString);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('ru-RU', {
day: '2-digit',
month: 'short',
});
};
const getMonthKey = (date) =>
${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')};
const guessCategory = (title) => {
if (!title) return 'Другое';
const t = title.toLowerCase();
if (/(super|market|магазин|магнит|кошелек|grocery|food|еда|dodo|kfc|burger)/.test(t)) return 'Еда';
if (/(аптека|pharm|apteka|drug)/.test(t)) return 'Здоровье';
if (/(taxi|такси|yandex go|bolt|uber|bus|metro)/.test(t)) return 'Транспорт';
if (/(аренда|rent|ipotek|ипотека|квартира)/.test(t)) return 'Жильё';
if (/(netflix|spotify|youtube|yandex plus|подписка|subscription)/.test(t)) return 'Подписки';
if (/(steam|psn|xbox|game|игр)/.test(t)) return 'Игры/развлечения';
if (/(cafe|кафе|coffee|кофе|restaurant|ресторан)/.test(t)) return 'Еда';
if (/(ozon|wildberries|wb|aliexpress)/.test(t)) return 'Покупки';
return 'Другое';
};
const sumBy = (arr, fn) => arr.reduce((acc, item, idx) => acc + (fn(item, idx) || 0), 0);
const buildMonthlyTotals = (expenses) => {
const totals = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
const key = getMonthKey(d);
totals[key] = (totals[key] || 0) + (Number(e.amount) || 0);
});
return totals;
};
const buildCategoryTotalsForMonth = (expenses, monthKey) => {
const m = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
if (getMonthKey(d) !== monthKey) return;
const cat = e.category || 'Другое';
m[cat] = (m[cat] || 0) + (Number(e.amount) || 0);
});
return m;
};
const forecastNextMonth = (expenses) => {
const totalsMap = buildMonthlyTotals(expenses);
const keys = Object.keys(totalsMap).sort();
if (keys.length === 0) return 0;
const ys = keys.map((k) => totalsMap[k]);
if (keys.length === 1) return ys[0];
const n = ys.length;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumX2 = 0;
for (let i = 0; i < n; i += 1) {
const x = i;
const y = ys[i];
sumX += x;
sumY += y;
sumXY += x * y;
sumX2 += x * x;
}
const denom = n * sumX2 - sumX * sumX;
const slope = denom === 0 ? 0 : (n * sumXY - sumX * sumY) / denom;
const intercept = (sumY - slope * sumX) / n;
const forecast = intercept + slope * n;
return forecast > 0 ? forecast : ys[ys.length - 1];
};
const detectAnomalies = (expenses) => {
if (!expenses.length) return [];
const amounts = expenses.map((e) => Number(e.amount) || 0);
const avg = sumBy(amounts, (a) => a) / amounts.length;
const threshold = Math.max(avg * 2, 0);
return expenses.filter((e) => (Number(e.amount) || 0) > threshold && threshold > 0);
};
const calculateDailyStreak = (expenses, monthlyIncome) => {
if (!expenses.length || !monthlyIncome) return 0;
const dailyBudget = monthlyIncome / 30;
if (dailyBudget <= 0) return 0;
const byDay = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
const key = d.toISOString().substring(0, 10);
byDay[key] = (byDay[key] || 0) + (Number(e.amount) || 0);
});
const today = new Date();
let streak = 0;
for (let i = 0; i < 365; i += 1) {
const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000);
const key = d.toISOString().substring(0, 10);
const spent = byDay[key] || 0;
if (spent <= dailyBudget) {
streak += 1;
} else {
break;
}
}
return streak;
};
const countNoSpendDaysThisMonth = (expenses) => {
if (!expenses.length) return 0;
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const spentDays = new Set();
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
if (d.getFullYear() !== year || d.getMonth() !== month) return;
spentDays.add(d.getDate());
});
return daysInMonth - spentDays.size;
};
const calculateFinancialHealth = (userData) => {
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const monthlyIncome = Number(settings.monthlyIncome) || Number(userData.income) || 0;
const now = new Date();
const currentMonthKey = getMonthKey(now);
const monthTotals = buildMonthlyTotals(expenses);
const totalThisMonth = monthTotals[currentMonthKey] || 0;
const savings = Math.max(0, monthlyIncome - totalThisMonth);
const savingsRate = monthlyIncome > 0 ? savings / monthlyIncome : 0;
const unnecessary = sumBy(expenses, (e) => {
if (e.type === 'income') return 0;
const d = new Date(e.date || new Date());
if (getMonthKey(d) !== currentMonthKey) return 0;
const c = (e.category || '').toLowerCase();
if (
c.includes('игр') ||
c.includes('развлеч') ||
c.includes('подпис') ||
c.includes('покуп') ||
c.includes('кафе') ||
c.includes('bar') ||
c.includes('club')
) {
return Number(e.amount) || 0;
}
return 0;
});
const unnecessaryShare = monthlyIncome > 0 ? unnecessary / monthlyIncome : 0;
let score = 50;
score += Math.min(20, savingsRate * 40);
score -= Math.min(15, unnecessaryShare * 30);
if (userData.goals && userData.goals.length) score += 5;
if (expenses.length > 20) score += 5;
score = Math.max(0, Math.min(100, Math.round(score)));
let label = 'Нужно больше данных';
if (score >= 80) {
label = 'Отличное состояние';
} else if (score >= 60) {
label = 'Хорошее состояние';
} else if (score >= 40) {
label = 'Среднее, есть над чем поработать';
} else {
label = 'Слабое, нужен план';
}
return {
score,
label,
monthlyIncome,
totalThisMonth,
savings,
unnecessary,
};
};
// простое шифрование заметок по PIN (XOR + hex, не для продакшена)
const encryptNote = (plain, pin) => {
if (!plain) return '';
if (!pin) return plain;
const digits = pin.split('').map((d) => parseInt(d, 10) || 0);
if (!digits.length) return plain;
let out = '';
for (let i = 0; i < plain.length; i += 1) {
const code = plain.charCodeAt(i) ^ digits[i % digits.length];
out += code.toString(16).padStart(2, '0');
}
return out;
};
const decryptNote = (cipher, pin) => {
if (!cipher) return '';
if (!pin) return cipher;
const digits = pin.split('').map((d) => parseInt(d, 10) || 0);
if (!digits.length) return cipher;
try {
let out = '';
for (let i = 0; i < cipher.length; i += 2) {
const hex = cipher.substr(i, 2);
const code = parseInt(hex, 16) ^ digits[(i / 2) % digits.length];
out += String.fromCharCode(code);
}
return out;
} catch (e) {
return '';
}
};
const buildSmartReminders = (userData) => {
const reminders = [];
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const now = new Date();
const nowKey = getMonthKey(now);
const last7Total = sumBy(expenses, (e) => {
if (e.type === 'income') return 0;
const d = new Date(e.date || new Date());
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24);
if (diff >= 0 && diff <= 7) {
return Number(e.amount) || 0;
}
return 0;
});
const weeklyBudget = Number(settings.weeklyBudget) || 0;
if (weeklyBudget > 0 && last7Total > weeklyBudget) {
reminders.push(
За последние 7 дней вы уже вышли за недельный бюджет на ${formatCurrency( last7Total - weeklyBudget, settings.currency || DEFAULT_CURRENCY, )}.,
);
} else if (weeklyBudget > 0 && last7Total > weeklyBudget * 0.8) {
reminders.push('Вы приблизились к недельному бюджету — самое время притормозить расходы.');
}
(userData.goals || []).forEach((g) => {
const target = Number(g.targetAmount) || 0;
const saved = Number(g.savedAmount) || 0;
if (!target) return;
const d = g.deadline ? new Date(g.deadline) : null;
const left = target - saved;
if (left <= 0 || !d) return;
const daysLeft = Math.round((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (daysLeft <= 0) return;
const perDay = left / daysLeft;
if (perDay > 0) {
reminders.push(
До цели «${g.title}» осталось ${formatCurrency( left, settings.currency || DEFAULT_CURRENCY, )}. Нужно откладывать примерно ${formatCurrency( perDay, settings.currency || DEFAULT_CURRENCY, )} в день.,
);
}
});
const monthTotals = buildMonthlyTotals(expenses);
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevKey = getMonthKey(prevMonth);
const totalNow = monthTotals[nowKey] || 0;
const totalPrev = monthTotals[prevKey] || 0;
if (totalPrev > 0 && totalNow > totalPrev * 1.15) {
const diff = totalNow - totalPrev;
reminders.push(
В этом месяце ваши траты уже на ${formatCurrency( diff, settings.currency || DEFAULT_CURRENCY, )} больше, чем в прошлом.,
);
}
if (!reminders.length) {
reminders.push('Вы молодец — критичных напоминаний сейчас нет 🎉');
}
return reminders;
};
const buildInsightsSummary = (userData) => {
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const now = new Date();
const currentMonthKey = getMonthKey(now);
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevKey = getMonthKey(prevMonth);
const monthTotals = buildMonthlyTotals(expenses);
const totalNow = monthTotals[currentMonthKey] || 0;
const totalPrev = monthTotals[prevKey] || 0;
const catNow = buildCategoryTotalsForMonth(expenses, currentMonthKey);
const catPrev = buildCategoryTotalsForMonth(expenses, prevKey);
const entriesNow = Object.entries(catNow);
const topCat = entriesNow.sort((a, b) => b[1] - a[1])[0];
const currency = settings.currency || DEFAULT_CURRENCY;
const anomalies = detectAnomalies(expenses).slice(0, 3);
const lines = [];
if (totalPrev > 0 && totalNow > 0) {
const delta = totalNow - totalPrev;
const pct = (delta / totalPrev) * 100;
if (Math.abs(pct) >= 5) {
if (pct > 0) {
lines.push(
Общие расходы выросли на ${pct.toFixed(1)}% по сравнению с прошлым месяцем (${formatCurrency( totalNow, currency, )} против ${formatCurrency(totalPrev, currency)}).,
);
} else {
lines.push(
Вы тратите на ${Math.abs(pct).toFixed( 1, )}% меньше, чем в прошлом месяце — отличный прогресс 👏,
);
}
}
}
if (topCat) {
lines.push(
Больше всего вы тратите на категорию «${topCat[0]}» — ${formatCurrency( topCat[1], currency, )} в этом месяце.,
);
}
Object.keys(catNow).forEach((c) => {
const nowVal = catNow[c];
const prevVal = catPrev[c] || 0;
if (prevVal <= 0) return;
const pct = (nowVal - prevVal) / prevVal;
if (pct >= 0.25 && nowVal > 0) {
lines.push(
Категория «${c}» выросла на ${Math.round( pct * 100, )}% относительно прошлого месяца — стоит проверить, можно ли там сэкономить.,
);
}
});
if (anomalies.length) {
const txt = anomalies
.map((a) => "${a.title}" на ${formatCurrency(a.amount, currency)})
.join(', ');
lines.push(Обнаружены разовые крупные траты: ${txt}. Убедитесь, что они действительно нужны.);
}
if (!lines.length) {
lines.push('Пока недостаточно данных для глубокого анализа. Начните добавлять траты 🙂');
}
return lines;
};
const generateAiAnswer = (promptRaw, userData) => {
const prompt = promptRaw.toLowerCase();
const health = calculateFinancialHealth(userData);
const insights = buildInsightsSummary(userData);
const currency = (userData.settings && userData.settings.currency) || DEFAULT_CURRENCY;
const expenses = userData.expenses || [];
const monthlyTotals = buildMonthlyTotals(expenses);
const nowKey = getMonthKey(new Date());
const totalThisMonth = monthlyTotals[nowKey] || 0;
if (!expenses.length && !prompt.includes('регистрац')) {
return 'У меня пока нет ваших данных. Добавьте несколько трат и доходов — тогда я смогу давать персональные советы.';
}
if (prompt.includes('сократить') || prompt.includes('эконом') || prompt.includes('сэкономить')) {
const catNow = buildCategoryTotalsForMonth(expenses, nowKey);
const entries = Object.entries(catNow).sort((a, b) => b[1] - a[1]);
const top3 = entries.slice(0, 3);
const tips = top3
.map(
([c, v]) =>
• «${c}» — ${formatCurrency( v, currency, )} в месяц. Попробуйте снизить хотя бы на 10–20%.,
)
.join('\n');
return (
Сейчас ваши траты за месяц: ${formatCurrency( totalThisMonth, currency, )}. Чтобы сократить их, лучше всего начать с самых крупных категорий:\n\n${tips}\n\n +
'Дополнительно:\n' +
'• установите недельный бюджет и фиксируйте все траты;\n' +
'• избегайте спонтанных покупок — делайте «паузу 24 часа»;\n' +
'• для подписок — оставьте только то, чем пользуетесь каждую неделю.'
);
}
if (prompt.includes('как распределить') || prompt.includes('зарплат')) {
const match = promptRaw.match(/(\d[\d\s]{2,})/);
const income = match ? parseInt(match[1].replace(/\s/g, ''), 10) : health.monthlyIncome || 0;
if (!income) {
return 'Напишите, пожалуйста, размер зарплаты (например: «Как распределить зарплату 300 000?»), или задайте месячный доход в настройках. Тогда я предложу детальный план распределения.';
}
const needs = income * 0.5;
const wants = income * 0.3;
const saving = income * 0.2;
return (
Классическая модель 50/30/20 для зарплаты ${formatCurrency(income, currency)}:\n\n +
• 50% (${formatCurrency(needs, currency)}) — обязательные расходы (жильё, еда, транспорт, кредиты);\n +
• 30% (${formatCurrency(wants, currency)}) — «хотелки» и развлечения;\n +
• 20% (${formatCurrency(saving, currency)}) — сбережения и цели.\n\n +
'Вы можете адаптировать: если есть кредиты — уменьшите «хотелки» до 10–15% и направьте разницу на досрочное погашение и накопления.'
);
}
if (prompt.includes('стоит ли покупать') || prompt.includes('покупать')) {
return (
'Задайте себе три вопроса перед покупкой:\n' +
'1) Нужна ли вещь прямо сейчас или это импульс?\n' +
'2) Сколько часов вашей работы она стоит?\n' +
'3) Через месяц вы будете так же рады этой покупке?\n\n' +
'Если сумма покупки >10% от месячного дохода — хорошо бы выдержать «паузу 24 часа». ' +
'Также посмотрите, не мешает ли эта покупка вашим финансовым целям (накоплениям и т.п.).'
);
}
if (prompt.includes('что если') || prompt.includes('если буду')) {
return (
'Функция «Что если?» уже доступна на вкладке «Цели» — там можно смоделировать, что будет, если сократить траты на кафе или начать откладывать больше. ' +
'Если хотите, сформулируйте сценарий конкретно (например: «Что будет, если каждый месяц откладывать на 20 000 больше?»), и я прикину эффект в чате.'
);
}
const baseInsights = insights.slice(0, 2).join('\n\n');
return (
Краткий обзор вашего финансового состояния:\n +
• Индекс финансового здоровья: ${health.score}/100 (${health.label}).\n +
• Траты в этом месяце: ${formatCurrency(totalThisMonth, currency)}.\n +
• Потенциальные сбережения: ${formatCurrency(health.savings, currency)}.\n\n +
${baseInsights}\n\n +
'Задавайте мне конкретные вопросы: например, «Как сократить расходы на 20%?» или ' +
'«Помоги перераспределить бюджет с учётом цели на 800 000».'
);
};
// helper для дефолтных данных пользователя
const createDefaultUserData = (themeMode, profile = {}) => ({
income: 0,
expenses: [],
goals: [],
subscriptions: [],
settings: {
monthlyIncome: 0,
weeklyBudget: 0,
currency: DEFAULT_CURRENCY,
antiImpulseEnabled: false,
antiImpulseThreshold: 0,
themeMode: themeMode || 'dark',
},
profile,
secureNote: '',
});
// helper для выбора картинки из галереи
const pickImageFromLibrary = async () => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Нет доступа', 'Разрешите доступ к фото в настройках устройства.');
return null;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.8,
});
if (result.canceled) return null;
const asset = result.assets && result.assets[0];
if (!asset) return null;
return asset.uri;
};
// ---------- OCR + ИИ-проверка чеков ----------
// Заглушка для OCR. Здесь вы подключаете Google ML Kit / Tesseract.
// Например: @react-native-ml-kit/text-recognition или tesseract.js.
const runOcrOnReceiptImage = async (uri) => {
console.log('runOcrOnReceiptImage placeholder, uri:', uri);
// TODO: заменить на реальный вызов Vision OCR
// Пример:
// const result = await MLKitTextRecognition.recognize(uri);
// return result.text;
// Сейчас вернём тестовый текст, чтобы отладить пайплайн.
return 'Супермаркет Пример\nИТОГО 1234.56\nСпасибо за покупку';
};
const parseReceiptOcrText = (text) => {
if (!text) return null;
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) return null;
const normalized = text.replace(',', '.');
const matches = normalized.match(/(\d+[.]\d{2}|\d{3,})/g);
let amount = 0;
if (matches && matches.length) {
const nums = matches
.map((s) => parseFloat(s))
.filter((n) => Number.isFinite(n));
if (nums.length) {
amount = Math.max(...nums);
}
}
let titleLine = '';
for (const line of lines) {
const lower = line.toLowerCase();
if (
/итог|итого|total|сумма|наличн|безнал|касса/.test(lower) ||
/^[\d\s.,]+$/.test(line)
) {
continue;
}
titleLine = line;
break;
}
if (!titleLine) {
titleLine = 'Покупка по чеку';
}
const category = guessCategory(titleLine);
return {
title: titleLine,
amount,
category,
};
};
// ---------- небольшие компоненты ----------
const CategoryBarChart = ({ data, theme }) => {
if (!data || !data.length) return null;
const max = Math.max(...data.map((d) => d.value || 0));
if (!max) return null;
return (
<View style={base.chartRow}>
{data.map((item) => {
const pct = (item.value / max) * 100;
return (
<View key={item.label} style={{ marginBottom: 6 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>{item.label}</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{formatCurrency(item.value)}
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 3 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: theme.accent,
width: ${pct}%,
},
]}
/>
</View>
</View>
);
})}
</View>
);
};
const SimpleMonthlyChart = ({ expenses, theme }) => {
const totalsMap = buildMonthlyTotals(expenses || []);
const keys = Object.keys(totalsMap).sort();
if (!keys.length) return null;
const items = keys.slice(-6).map((k) => ({ key: k, value: totalsMap[k] }));
const max = Math.max(...items.map((i) => i.value));
if (!max) return null;
return (
<View style={{ marginTop: 4 }}>
{items.map((item) => {
const [year, month] = item.key.split('-');
const label = ${month}.${year.slice(-2)};
const pct = (item.value / max) * 100;
return (
<View key={item.key} style={{ marginBottom: 4 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>{label}</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{formatCurrency(item.value)}
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 3 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: theme.accentSoft,
width: ${pct}%,
},
]}
/>
</View>
</View>
);
})}
</View>
);
};
const SpendingGauge = ({ monthlyIncome, spent, theme, currency }) => {
if (!monthlyIncome) return null;
const ratio = monthlyIncome > 0 ? spent / monthlyIncome : 0;
const pct = Math.max(0, Math.min(1, ratio));
const labelPct = (pct * 100).toFixed(0);
return (
<View style={{ marginTop: 10 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>Расходы от дохода</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{labelPct}% ({formatCurrency(spent, currency)})
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 4, height: 12 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: ratio > 1 ? theme.danger : theme.accent,
width: ${pct * 100}%,
},
]}
/>
</View>
{ratio > 1 && (
<Text style={[base.tinyText, { color: theme.danger, marginTop: 4 }]}>
Вы вышли за рамки месячного дохода — стоит пересмотреть план.
</Text>
)}
</View>
);
};
// ---------- экраны ----------
const AuthScreen = ({ users, onLogin, onRegister, theme }) => {
const [mode, setMode] = useState('login'); // 'login' | 'register'
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [pin, setPin] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [phone, setPhone] = useState('');
const [birthDate, setBirthDate] = useState('');
const [avatarUri, setAvatarUri] = useState('');
const [error, setError] = useState('');
const toggleMode = () => {
setError('');
setMode((m) => (m === 'login' ? 'register' : 'login'));
};
const handleLoginPress = () => {
setError('');
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail || !password) {
setError('Заполните email и пароль.');
return;
}
const user = users.find((u) => u.email === normalizedEmail);
if (!user || user.password !== password) {
setError('Неверный email или пароль.');
return;
}
onLogin(user);
};
const handleRegisterPress = () => {
setError('');
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail || !password) {
setError('Заполните email и пароль.');
return;
}
if (!normalizedEmail.includes('@')) {
setError('Некорректный email.');
return;
}
if (password.length < 4) {
setError('Пароль должен быть не короче 4 символов.');
return;
}
if (!firstName.trim()) {
setError('Укажите имя.');
return;
}
if (!phone.trim()) {
setError('Укажите номер телефона.');
return;
}
if (users.some((u) => u.email === normalizedEmail)) {
setError('Пользователь с таким email уже существует.');
return;
}
const newUser = {
id: u_${Date.now()},
email: normalizedEmail,
password,
pin: pin || '',
createdAt: new Date().toISOString(),
};
const profile = {
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim(),
birthDate: birthDate.trim(),
avatarUri: avatarUri || '',
};
onRegister(newUser, profile);
};
const handlePickAvatar = async () => {
const uri = await pickImageFromLibrary();
if (uri) {
setAvatarUri(uri);
}
};
return (
<SafeAreaView
style={[
base.container,
{ backgroundColor: theme.background },
]}
>
<StatusBar
barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'}
/>
<KeyboardAvoidingView
style={base.flex1}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={[base.authContainer]}>
<View style={{ marginBottom: 32 }}>
<Text style={[base.title, { color: theme.text }]}>
Финансовый ИИ-помощник
</Text>
<Text style={[base.subtitle, { color: theme.textMuted }]}>
Управляй доходами, расходами, целями и сканируй чеки с ИИ.
</Text>
</View>
text<View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[ base.cardTitle, { color: theme.text }, ]} > {mode === 'login' ? 'Вход' : 'Регистрация'} </Text> <View style={[ base.pill, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.pillText, { color: theme.accent }, ]} > ⚡ ИИ + чек-сканер </Text> </View> </View> {error ? ( <Text style={[base.errorText, { color: theme.danger }]}>{error}</Text> ) : null} {mode === 'register' && ( <> <View style={base.row}> <TouchableOpacity onPress={handlePickAvatar}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> + </Text> </View> )} </TouchableOpacity> <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото профиля (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте аватар, чтобы приложение стало более личным. </Text> </View> </View> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} keyboardType="phone-pad" value={phone} onChangeText={setPhone} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </> )} <TextInput placeholder="Email" placeholderTextColor={theme.textMuted} autoCapitalize="none" keyboardType="email-address" value={email} onChangeText={setEmail} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Пароль" placeholderTextColor={theme.textMuted} secureTextEntry value={password} onChangeText={setPassword} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> {mode === 'register' && ( <TextInput placeholder="PIN (необязательно, для шифрования заметок)" placeholderTextColor={theme.textMuted} value={pin} onChangeText={setPin} keyboardType="number-pad" maxLength={6} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> )} <TouchableOpacity onPress={mode === 'login' ? handleLoginPress : handleRegisterPress} style={[ base.button, { backgroundColor: theme.accent, marginTop: 8 }, ]} > <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {mode === 'login' ? 'Войти' : 'Зарегистрироваться'} </Text> </TouchableOpacity> <TouchableOpacity onPress={toggleMode}> <Text style={[ base.link, { color: theme.textMuted }, ]} > {mode === 'login' ? 'Нет аккаунта? Создать' : 'Уже есть аккаунт? Войти'} </Text> </TouchableOpacity> </View> <View style={{ marginTop: 24 }}> <Text style={[ base.smallText, { color: theme.textMuted }, ]} > Данные хранятся только локально на устройстве (AsyncStorage). Для реального приложения стоит добавить серверный бэкап и более серьёзную криптографию. </Text> </View> </View> </KeyboardAvoidingView> </SafeAreaView>
);
};
const HomeScreen = ({ userData, theme }) => {
const health = calculateFinancialHealth(userData);
const insights = buildInsightsSummary(userData);
const reminders = buildSmartReminders(userData);
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const now = new Date();
const currentMonthKey = getMonthKey(now);
const catNowMap = buildCategoryTotalsForMonth(expenses, currentMonthKey);
const categoryData = Object.entries(catNowMap)
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value);
const forecast = forecastNextMonth(expenses);
const streak = calculateDailyStreak(
expenses,
Number(settings.monthlyIncome) || Number(userData.income) || 0,
);
const noSpendDays = countNoSpendDaysThisMonth(expenses);
const profile = userData.profile || {};
const firstName = profile.firstName || '';
const badges = [];
if (health.score >= 80) badges.push('Экономист месяца');
if (streak >= 7) badges.push('7 дней в плюсе');
if (noSpendDays >= 5) badges.push('Дни без трат');
if (expenses.length >= 50) badges.push('Финансовый самурай');
return (
<ScrollView
style={[
base.content,
{ backgroundColor: theme.background },
]}
showsVerticalScrollIndicator={false}
>
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.rowBetween}>
<View style={{ flex: 1 }}>
<Text style={[base.cardSubtitle, { color: theme.textMuted }]}>
Индекс финансового здоровья
</Text>
<Text
style={[
{ fontSize: 32, fontWeight: '700', marginTop: 4 },
{ color: theme.text },
]}
>
{health.score}
<Text style={{ fontSize: 16 }}>/100</Text>
</Text>
{firstName ? (
<Text
style={[
base.tinyText,
{ color: theme.textMuted, marginTop: 4 },
]}
>
{firstName}, это ваш персональный индекс.
</Text>
) : null}
</View>
<View
style={[
{
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
},
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.tinyText, { color: theme.textMuted }]}>Статус</Text>
<Text
style={[
base.smallText,
{ color: theme.text, fontWeight: '600', marginTop: 2 },
]}
>
{health.label}
</Text>
</View>
</View>
text<View style={{ marginTop: 10 }}> <View style={base.rowBetween}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Доход / мес.</Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {health.monthlyIncome ? formatCurrency(health.monthlyIncome, currency) : 'Не задан'} </Text> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Траты в этом месяце </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(health.totalThisMonth, currency)} </Text> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Сбережения</Text> <Text style={[ base.smallText, { color: health.savings >= 0 ? theme.success : theme.danger, marginTop: 2, }, ]} > {formatCurrency(health.savings, currency)} </Text> </View> <SpendingGauge monthlyIncome={health.monthlyIncome} spent={health.totalThisMonth} theme={theme} currency={currency} /> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Привычки</Text> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Серия дней в рамках бюджета: {streak} </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Дней без трат в этом месяце: {noSpendDays} </Text> </View> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Бейджи</Text> <View style={[base.row, { flexWrap: 'wrap', marginTop: 4 }]}> {badges.length ? ( badges.map((b) => ( <View key={b} style={[ base.badge, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.tinyText, { color: theme.accent }, ]} > ✨ {b} </Text> </View> )) ) : ( <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Собирайте награды, держась в рамках бюджета. </Text> )} </View> </View> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>AI-инсайты</Text> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>🧩 Анализ</Text> </View> </View> {insights.slice(0, 3).map((line, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{line}</Text> </View> ))} </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Категории</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Текущий месяц </Text> </View> <CategoryBarChart data={categoryData} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Динамика</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Прогноз: {formatCurrency(forecast, currency)} </Text> </View> <SimpleMonthlyChart expenses={expenses} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Умные напоминания</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔔</Text> </View> {reminders.map((r, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{r}</Text> </View> ))} </View> </ScrollView>
);
};
const ExpensesScreen = ({ userData, onUpdateUserData, theme }) => {
const [title, setTitle] = useState('');
const [amount, setAmount] = useState('');
const [category, setCategory] = useState('');
const [isIncome, setIsIncome] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [lastReceiptPreview, setLastReceiptPreview] = useState(null); // { uri, text }
const expenses = [...(userData.expenses || [])].sort(
(a, b) => new Date(b.date || 0).getTime() - new Date(a.date || 0).getTime(),
);
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const antiImpulseEnabled = settings.antiImpulseEnabled;
const antiImpulseThreshold = Number(settings.antiImpulseThreshold) || 0;
const addEntry = (n, guessed, customTitle, extraMeta) => {
const entry = {
id: exp_${Date.now()},
title: (customTitle || title).trim(),
amount: n,
category: guessed,
type: isIncome ? 'income' : 'expense',
date: new Date().toISOString(),
...(extraMeta ? { meta: extraMeta } : {}),
};
onUpdateUserData((prev) => ({
...prev,
expenses: [...(prev.expenses || []), entry],
}));
setTitle('');
setAmount('');
setCategory('');
setIsIncome(false);
};
const handleAdd = () => {
const n = parseFloat(String(amount).replace(',', '.'));
if (!title || !Number.isFinite(n) || n <= 0) {
Alert.alert('Ошибка', 'Введите название и сумму.');
return;
}
const guessed = category || guessCategory(title);
textif (!isIncome && antiImpulseEnabled && antiImpulseThreshold > 0 && n >= antiImpulseThreshold) { Alert.alert( 'Анти-импульсная защита', `Покупка на ${formatCurrency( n, currency, )} превышает ваш лимит в ${formatCurrency(antiImpulseThreshold, currency)}.\n\n` + 'Вы уверены, что хотите продолжить?', [ { text: 'Отменить', style: 'cancel' }, { text: 'Купить', style: 'destructive', onPress: () => addEntry(n, guessed), }, ], ); return; } addEntry(n, guessed);
};
const handleScanReceipt = async () => {
try {
setIsScanning(true);
const perm = await ImagePicker.requestCameraPermissionsAsync();
if (!perm.granted) {
Alert.alert('Нет доступа', 'Разрешите доступ к камере в настройках устройства.');
return;
}
textconst result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8, }); if (result.canceled) return; const asset = result.assets && result.assets[0]; if (!asset) return; const uri = asset.uri; const ocrText = await runOcrOnReceiptImage(uri); setLastReceiptPreview({ uri, text: ocrText }); const parsed = parseReceiptOcrText(ocrText); if (!parsed || !parsed.amount || parsed.amount <= 0) { Alert.alert( 'Не удалось распознать чек', 'Попробуйте сделать фото поярче и ровнее, чтобы мы могли прочитать сумму.', ); return; } const guessedCategory = parsed.category || guessCategory(parsed.title); const n = parsed.amount; // Авто-добавление в расходы onUpdateUserData((prev) => ({ ...prev, expenses: [ ...(prev.expenses || []), { id: `exp_receipt_${Date.now()}`, title: parsed.title || 'Покупка по чеку', amount: n, category: guessedCategory, type: 'expense', date: new Date().toISOString(), meta: { source: 'receipt', ocrText, imageUri: uri, }, }, ], })); Alert.alert( 'Чек добавлен', `Мы распознали сумму ${formatCurrency( n, currency, )} и категорию «${guessedCategory}».\nЗапись добавлена в историю расходов.`, ); } catch (e) { console.warn('Receipt scan error', e); Alert.alert('Ошибка', 'Не удалось обработать чек. Попробуйте ещё раз.'); } finally { setIsScanning(false); }
};
const totalExpenses = sumBy(expenses, (e) =>
e.type === 'income' ? 0 : Number(e.amount) || 0,
);
const totalIncome = sumBy(expenses, (e) =>
e.type === 'income' ? Number(e.amount) || 0 : 0,
);
return (
<View
style={[
base.content,
{ backgroundColor: theme.background },
]}
>
<ScrollView
style={{ flex: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* ИИ-проверка чеков */}
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<View style={{ flex: 1, paddingRight: 6 }}>
<Text style={[base.cardTitle, { color: theme.text }]}>
ИИ-проверка чеков
</Text>
<Text style={[base.tinyText, { color: theme.textMuted, marginTop: 2 }]}>
Сделайте фото чека — мы распознаем сумму и автоматически добавим расход.
</Text>
</View>
<View style={[base.pill, { backgroundColor: theme.accentSoft }]}>
<Text style={[base.pillText, { color: theme.accent }]}>🤖 Чек-сканер</Text>
</View>
</View>
text<TouchableOpacity onPress={handleScanReceipt} disabled={isScanning} style={[ base.button, { backgroundColor: isScanning ? theme.cardAlt : theme.accent, marginTop: 8, flexDirection: 'row', }, ]} > {isScanning && ( <ActivityIndicator size="small" style={{ marginRight: 8 }} color="#FFFFFF" /> )} <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {isScanning ? 'Сканируем чек...' : 'Сканировать чек с ИИ'} </Text> </TouchableOpacity> {lastReceiptPreview && ( <View style={[base.row, { marginTop: 10 }]}> <Image source={{ uri: lastReceiptPreview.uri }} style={{ width: 56, height: 56, borderRadius: 12, marginRight: 8 }} /> <View style={{ flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Последний чек (OCR) </Text> <Text numberOfLines={3} style={[base.smallText, { color: theme.text, marginTop: 2 }]} > {lastReceiptPreview.text || 'Текст не распознан'} </Text> </View> </View> )} </View> {/* Ручное добавление записи */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Новая запись</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}> Доход </Text> <Switch value={isIncome} onValueChange={setIsIncome} thumbColor={isIncome ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Магазин / описание" placeholderTextColor={theme.textMuted} value={title} onChangeText={(text) => { setTitle(text); if (!category) { setCategory(guessCategory(text)); } }} style={[base.inputInline, { color: theme.text }]} /> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} keyboardType="numeric" value={amount} onChangeText={setAmount} style={[base.inputInline, { color: theme.text }]} /> <Text style={[base.smallText, { color: theme.textMuted }]}>{currency}</Text> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Категория</Text> <ScrollView horizontal showsHorizontalScrollIndicator={false}> {DEFAULT_CATEGORIES.map((c) => ( <TouchableOpacity key={c} onPress={() => setCategory(c)} style={[ base.tag, { backgroundColor: category === c ? theme.accentSoft : theme.cardAlt, borderWidth: 1, borderColor: category === c ? theme.accent : theme.border, }, ]} > <Text style={[ base.tagText, { color: category === c ? theme.accent : theme.textMuted, }, ]} > {c} </Text> </TouchableOpacity> ))} </ScrollView> </View> <TouchableOpacity onPress={handleAdd} style={[ base.button, { backgroundColor: theme.accent, marginTop: 12 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить </Text> </TouchableOpacity> </View> {/* Итоги */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.rowBetween}> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего расходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalExpenses, currency)} </Text> </View> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего доходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalIncome, currency)} </Text> </View> </View> </View> {/* История */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>История</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> {expenses.length} записей </Text> </View> {expenses.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Добавьте первую трату или доход. </Text> ) : ( <FlatList data={expenses} keyExtractor={(item) => item.id} scrollEnabled={false} renderItem={({ item }) => { const isImpulseHighlight = item.type !== 'income' && antiImpulseEnabled && antiImpulseThreshold > 0 && Number(item.amount) >= antiImpulseThreshold; const isReceipt = item.meta && item.meta.source === 'receipt'; return ( <View> <View style={base.expenseItem}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> {item.title} </Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted }]}> {formatDate(item.date)} </Text> <View style={[ base.tag, { backgroundColor: theme.cardAlt, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.textMuted }, ]} > {item.category || 'Другое'} </Text> </View> {isReceipt && ( <View style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > ИИ-чек </Text> </View> )} </View> </View> <Text style={[ base.smallText, { color: item.type === 'income' ? theme.success : isImpulseHighlight ? theme.danger : theme.text, }, ]} > {item.type === 'income' ? '+' : '-'} {formatCurrency(item.amount, currency)} </Text> </View> <View style={[ base.divider, { backgroundColor: theme.border }, ]} /> </View> ); }} /> )} </View> </ScrollView> </View>
);
};
const GoalsScreen = ({ userData, onUpdateUserData, theme }) => {
const [title, setTitle] = useState('');
const [target, setTarget] = useState('');
const [months, setMonths] = useState('');
const [whatIfCafe, setWhatIfCafe] = useState('30');
const [whatIfSave, setWhatIfSave] = useState('10000');
const [goalImage, setGoalImage] = useState(null);
const [editGoalId, setEditGoalId] = useState(null);
const [editTitle, setEditTitle] = useState('');
const [editTarget, setEditTarget] = useState('');
const [editDeadlineMonths, setEditDeadlineMonths] = useState('');
const goals = userData.goals || [];
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const handleAddGoal = () => {
const t = parseFloat(String(target).replace(',', '.'));
const m = parseInt(months, 10);
if (!title || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) {
Alert.alert('Ошибка', 'Укажите цель, сумму и срок в месяцах.');
return;
}
const deadline = new Date();
deadline.setMonth(deadline.getMonth() + m);
const newGoal = {
id: goal_${Date.now()},
title: title.trim(),
targetAmount: t,
savedAmount: 0,
deadline: deadline.toISOString(),
imageUri: goalImage || null,
};
onUpdateUserData((prev) => ({
...prev,
goals: [...(prev.goals || []), newGoal],
}));
setTitle('');
setTarget('');
setMonths('');
setGoalImage(null);
};
const handleDeposit = (goalId, amount) => {
const n = amount || 0;
onUpdateUserData((prev) => ({
...prev,
goals: (prev.goals || []).map((g) =>
g.id === goalId
? { ...g, savedAmount: (Number(g.savedAmount) || 0) + n }
: g,
),
expenses: [
...(prev.expenses || []),
{
id: save_${Date.now()},
title: 'Перевод в копилку',
amount: n,
category: 'Сбережения',
type: 'expense',
date: new Date().toISOString(),
},
],
}));
};
const handlePickGoalImage = async () => {
const uri = await pickImageFromLibrary();
if (uri) setGoalImage(uri);
};
const handleEditGoalPress = (goal) => {
const now = new Date();
const deadline = goal.deadline ? new Date(goal.deadline) : null;
const monthsLeft =
deadline && !Number.isNaN(deadline.getTime())
? Math.max(
1,
Math.round(
(deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30),
),
)
: '';
textsetEditGoalId(goal.id); setEditTitle(goal.title); setEditTarget(String(goal.targetAmount || '')); setEditDeadlineMonths(monthsLeft ? String(monthsLeft) : '');
};
const handleSaveEditedGoal = () => {
if (!editGoalId) return;
const t = parseFloat(String(editTarget).replace(',', '.'));
const m = parseInt(editDeadlineMonths, 10);
if (!editTitle || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) {
Alert.alert('Ошибка', 'Укажите корректные данные для цели.');
return;
}
const newDeadline = new Date();
newDeadline.setMonth(newDeadline.getMonth() + m);
textonUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).map((g) => g.id === editGoalId ? { ...g, title: editTitle.trim(), targetAmount: t, deadline: newDeadline.toISOString(), } : g, ), })); setEditGoalId(null); setEditTitle(''); setEditTarget(''); setEditDeadlineMonths('');
};
const handleCancelEdit = () => {
setEditGoalId(null);
setEditTitle('');
setEditTarget('');
setEditDeadlineMonths('');
};
const handleDeleteGoal = (goalId) => {
Alert.alert(
'Удалить цель',
'Вы уверены, что хотите удалить эту цель? Прогресс по ней будет потерян.',
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Удалить',
style: 'destructive',
onPress: () => {
onUpdateUserData((prev) => ({
...prev,
goals: (prev.goals || []).filter((g) => g.id !== goalId),
}));
},
},
],
);
};
const now = new Date();
const totalSavedAll = sumBy(goals, (g) => Number(g.savedAmount) || 0);
const totalTargetAll = sumBy(goals, (g) => Number(g.targetAmount) || 0);
const cafeReduce = parseFloat(String(whatIfCafe).replace(',', '.')) || 0;
const extraWeekly = parseFloat(String(whatIfSave).replace(',', '.')) || 0;
const cafeCurrent = sumBy(userData.expenses || [], (e) => {
if (e.type === 'income') return 0;
const c = (e.category || '').toLowerCase();
if (c.includes('еда') || c.includes('кафе')) {
const d = new Date(e.date || new Date());
const key = getMonthKey(d);
if (key === getMonthKey(now)) {
return Number(e.amount) || 0;
}
}
return 0;
});
const cafePotential = cafeCurrent * (cafeReduce / 100);
const whatIfMonthlyExtra = cafePotential + extraWeekly * 4;
return (
<ScrollView
style={[
base.content,
{ backgroundColor: theme.background },
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Блок создания цели */}
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<Text style={[base.cardTitle, { color: theme.text }]}>Цели и копилки</Text>
<View style={[base.pill, { backgroundColor: theme.accentSoft }]}>
<Text style={[base.pillText, { color: theme.accent }]}>💎 Планы</Text>
</View>
</View>
<Text style={[base.tinyText, { color: theme.textMuted }]}>
Всего накоплено: {formatCurrency(totalSavedAll, currency)} из{' '}
{formatCurrency(totalTargetAll || 0, currency)}
</Text>
text<View style={{ marginTop: 12 }}> <TouchableOpacity onPress={handlePickGoalImage} style={[ base.row, { marginBottom: 8, }, ]} > {goalImage ? ( <Image source={{ uri: goalImage }} style={[base.goalImage, { width: 80, height: 80 }]} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}>📷</Text> </View> )} <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото цели (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте визуал — так легче держаться плана. </Text> </View> </TouchableOpacity> <TextInput placeholder="Название цели (например, «Ноутбук»)" placeholderTextColor={theme.textMuted} value={title} onChangeText={setTitle} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={target} onChangeText={setTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={months} onChangeText={setMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <TouchableOpacity onPress={handleAddGoal} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить цель </Text> </TouchableOpacity> </View> </View> {/* Список целей с редактированием и удалением */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Мои цели</Text> </View> {goals.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Создайте первую цель, чтобы следить за прогрессом. </Text> ) : ( goals.map((g) => { const targetAmount = Number(g.targetAmount) || 0; const savedAmount = Number(g.savedAmount) || 0; const pct = targetAmount ? Math.min(100, (savedAmount / targetAmount) * 100) : 0; const deadline = g.deadline ? new Date(g.deadline) : null; const daysLeft = deadline && !Number.isNaN(deadline.getTime()) ? Math.round( (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), ) : null; const left = Math.max(0, targetAmount - savedAmount); const perWeek = daysLeft && daysLeft > 0 ? (left / daysLeft) * 7 : left / 4 || 0; const isEditing = editGoalId === g.id; return ( <View key={g.id} style={{ marginBottom: 12 }}> <View style={base.rowBetween}> <Text style={[base.smallText, { color: theme.text, maxWidth: '70%' }]}> {g.title} </Text> <View style={base.row}> <TouchableOpacity onPress={() => handleEditGoalPress(g)}> <Text style={[base.tinyText, { color: theme.accent, marginRight: 12 }]}> Изм. </Text> </TouchableOpacity> <TouchableOpacity onPress={() => handleDeleteGoal(g.id)}> <Text style={[base.tinyText, { color: theme.danger }]}> Удалить </Text> </TouchableOpacity> </View> </View> {g.imageUri ? ( <Image source={{ uri: g.imageUri }} style={base.goalImage} /> ) : null} <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accent, width: `${pct}%`, }, ]} /> </View> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> {formatCurrency(savedAmount, currency)} из{' '} {formatCurrency(targetAmount, currency)} </Text> {daysLeft !== null && ( <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 4 }, ]} > Осталось ~{daysLeft} дн. </Text> )} </View> {left > 0 && ( <View style={base.rowBetween}> <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 2 }, ]} > Чтобы успеть: ~{formatCurrency(perWeek, currency)} в неделю </Text> <TouchableOpacity onPress={() => handleDeposit(g.id, 10000)} style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 2, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > +10 000 в копилку </Text> </TouchableOpacity> </View> )} {isEditing && ( <View style={{ marginTop: 8, padding: 10, borderRadius: 12, backgroundColor: theme.cardAlt, }} > <Text style={[base.tinyText, { color: theme.textMuted, marginBottom: 4 }]}> Редактирование цели </Text> <TextInput placeholder="Название цели" placeholderTextColor={theme.textMuted} value={editTitle} onChangeText={setEditTitle} style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={editTarget} onChangeText={setEditTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={editDeadlineMonths} onChangeText={setEditDeadlineMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={base.rowBetween}> <TouchableOpacity onPress={handleSaveEditedGoal} style={[ base.button, { flex: 1, backgroundColor: theme.accent, marginTop: 4, marginRight: 4, }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить </Text> </TouchableOpacity> <TouchableOpacity onPress={handleCancelEdit} style={[ base.button, { flex: 1, backgroundColor: theme.card, marginTop: 4, marginLeft: 4, borderWidth: 1, borderColor: theme.border, }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Отмена </Text> </TouchableOpacity> </View> </View> )} <View style={[ base.divider, { backgroundColor: theme.border, marginTop: 8 }, ]} /> </View> ); }) )} </View> {/* Блок «Что если?» */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>«Что если?»</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧪 Симулятор</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Смоделируйте изменения привычек и посмотрите влияние на накопления. </Text> <View style={{ marginTop: 8 }}> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> На сколько % сократить кафе/еду? </Text> <TextInput placeholder="30" placeholderTextColor={theme.textMuted} value={whatIfCafe} onChangeText={setWhatIfCafe} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Доп. отложить в неделю </Text> <TextInput placeholder="10 000" placeholderTextColor={theme.textMuted} value={whatIfSave} onChangeText={setWhatIfSave} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.smallText, { color: theme.text }]}> При таком сценарии вы сможете дополнительно накопить около{' '} {formatCurrency(whatIfMonthlyExtra, currency)} в месяц. </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> За год это будет примерно{' '} {formatCurrency(whatIfMonthlyExtra * 12, currency)} — почти целая отдельная цель 💡 </Text> </View> </View> </View> </ScrollView>
);
};
const AiChatScreen = ({ userData, theme }) => {
const [messages, setMessages] = useState([
{
id: 'm_hello',
from: 'ai',
text: 'Привет! Я твой финансовый ИИ-ассистент. Задавай вопросы: «Как сократить расходы на 20%?», «Как распределить зарплату?», «Что будет, если начать откладывать больше?»',
},
]);
const [input, setInput] = useState('');
const [voiceOn, setVoiceOn] = useState(false);
const sendText = (text) => {
const trimmed = text.trim();
if (!trimmed) return;
const userMsg = {
id: m_${Date.now()}_u,
from: 'user',
text: trimmed,
};
const aiText = generateAiAnswer(trimmed, userData);
const aiMsg = {
id: m_${Date.now()}_ai,
from: 'ai',
text: aiText,
};
setMessages((prev) => [...prev, userMsg, aiMsg]);
setInput('');
if (voiceOn) {
Speech.stop();
Speech.speak(aiText, { language: 'ru-RU' });
}
};
const handleSend = () => {
sendText(input);
};
const quickPrompts = [
'Как сократить расходы на 20%?',
'Как распределить зарплату 300 000?',
'Стоит ли покупать новый телефон?',
'Сделай обзор моих трат.',
];
return (
<KeyboardAvoidingView
style={[
base.content,
{ backgroundColor: theme.background },
]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<Text style={[base.cardTitle, { color: theme.text }]}>ИИ-консультант</Text>
<View style={base.row}>
<Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}>
Голос
</Text>
<Switch
value={voiceOn}
onValueChange={setVoiceOn}
thumbColor={voiceOn ? theme.accent : '#9CA3AF'}
trackColor={{ true: theme.accentSoft, false: theme.cardAlt }}
/>
</View>
</View>
<Text style={[base.tinyText, { color: theme.textMuted }]}>
Ответы основаны на ваших реальных данных: расходах, доходах, целях.
</Text>
<View
style={{
marginTop: 8,
flexDirection: 'row',
flexWrap: 'wrap',
}}
>
{quickPrompts.map((p) => (
<TouchableOpacity
key={p}
onPress={() => sendText(p)}
style={[
base.badge,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.tinyText, { color: theme.text }]}>{p}</Text>
</TouchableOpacity>
))}
</View>
</View>
text<View style={[ base.card, { flex: 1, backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 8 }} showsVerticalScrollIndicator={false} > {messages.map((m) => { const isUser = m.from === 'user'; return ( <View key={m.id} style={[ base.chatBubble, isUser ? base.chatUser : base.chatAi, { backgroundColor: isUser ? theme.accent : theme.cardAlt, }, ]} > <Text style={[ base.tinyText, { color: isUser ? '#FFFFFF' : theme.textMuted }, ]} > {isUser ? 'Вы' : 'ИИ'} </Text> <Text style={[ base.smallText, { color: isUser ? '#FFFFFF' : theme.text, marginTop: 2, }, ]} > {m.text} </Text> </View> ); })} </ScrollView> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, marginTop: 4, }, ]} > <TextInput placeholder="Задайте вопрос по финансам..." placeholderTextColor={theme.textMuted} value={input} onChangeText={setInput} style={[base.inputInline, { color: theme.text }]} multiline /> <TouchableOpacity onPress={handleSend}> <Text style={[base.smallText, { color: theme.accent }]}>Отпр.</Text> </TouchableOpacity> </View> </View> </KeyboardAvoidingView>
);
};
const ProfileScreen = ({
currentUser,
userData,
onUpdateUserData,
theme,
onLogout,
onToggleTheme,
}) => {
const profile = userData.profile || {};
const [firstName, setFirstName] = useState(profile.firstName || '');
const [lastName, setLastName] = useState(profile.lastName || '');
const [phone, setPhone] = useState(profile.phone || '');
const [birthDate, setBirthDate] = useState(profile.birthDate || '');
const [avatarUri, setAvatarUri] = useState(profile.avatarUri || '');
const [monthlyIncome, setMonthlyIncome] = useState(
String((userData.settings && userData.settings.monthlyIncome) || ''),
);
const [weeklyBudget, setWeeklyBudget] = useState(
String((userData.settings && userData.settings.weeklyBudget) || ''),
);
const [currency, setCurrency] = useState(
(userData.settings && userData.settings.currency) || DEFAULT_CURRENCY,
);
const [antiImpulseEnabled, setAntiImpulseEnabled] = useState(
(userData.settings && userData.settings.antiImpulseEnabled) || false,
);
const [antiImpulseThreshold, setAntiImpulseThreshold] = useState(
String((userData.settings && userData.settings.antiImpulseThreshold) || ''),
);
const [pinInput, setPinInput] = useState('');
const [noteDraft, setNoteDraft] = useState('');
const [noteUnlocked, setNoteUnlocked] = useState(false);
useEffect(() => {
const savedEncrypted = userData.secureNote || '';
if (savedEncrypted && currentUser.pin) {
const dec = decryptNote(savedEncrypted, currentUser.pin);
setNoteDraft(dec);
} else if (savedEncrypted && !currentUser.pin) {
setNoteDraft(savedEncrypted);
} else {
setNoteDraft('');
}
}, [userData.secureNote, currentUser.pin]);
useEffect(() => {
const p = userData.profile || {};
setFirstName(p.firstName || '');
setLastName(p.lastName || '');
setPhone(p.phone || '');
setBirthDate(p.birthDate || '');
setAvatarUri(p.avatarUri || '');
}, [userData.profile]);
const handleSaveSettings = () => {
const m = parseFloat(String(monthlyIncome).replace(',', '.')) || 0;
const w = parseFloat(String(weeklyBudget).replace(',', '.')) || 0;
const anti =
parseFloat(String(antiImpulseThreshold).replace(',', '.')) || 0;
textonUpdateUserData((prev) => ({ ...prev, income: m, settings: { ...(prev.settings || {}), monthlyIncome: m, weeklyBudget: w, currency: currency || DEFAULT_CURRENCY, antiImpulseEnabled, antiImpulseThreshold: anti, }, profile: { ...(prev.profile || {}), firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), birthDate: birthDate.trim(), avatarUri: avatarUri || '', }, })); Alert.alert('Сохранено', 'Профиль и бюджет обновлены.');
};
const handleUnlockNote = () => {
if (!currentUser.pin) {
setNoteUnlocked(true);
return;
}
if (pinInput === currentUser.pin) {
const dec = decryptNote(userData.secureNote || '', currentUser.pin);
setNoteDraft(dec);
setNoteUnlocked(true);
} else {
Alert.alert('Ошибка', 'Неверный PIN.');
}
};
const handleSaveNote = () => {
let encrypted = noteDraft;
if (currentUser.pin) {
encrypted = encryptNote(noteDraft, currentUser.pin);
}
onUpdateUserData((prev) => ({
...prev,
secureNote: encrypted,
}));
Alert.alert('Сохранено', 'Заметка зашифрована и сохранена.');
};
const totalSubs = (userData.subscriptions || []).length;
const handlePickAvatar = async () => {
const uri = await pickImageFromLibrary();
if (uri) setAvatarUri(uri);
};
const displayName =
firstName || lastName
? ${firstName} ${lastName}.trim()
: currentUser.email;
return (
<ScrollView
style={[
base.content,
{ backgroundColor: theme.background },
]}
showsVerticalScrollIndicator={false}
>
{/* Профиль и быстрый статус */}
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<View style={base.row}>
<TouchableOpacity onPress={handlePickAvatar}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={base.avatarImage} />
) : (
<View
style={[
base.avatarCircle,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.smallText, { color: theme.textMuted }]}>
{firstName
? firstName.charAt(0).toUpperCase()
: currentUser.email.charAt(0).toUpperCase()}
</Text>
</View>
)}
</TouchableOpacity>
<View style={{ marginLeft: 10 }}>
<Text style={[base.cardTitle, { color: theme.text }]}>
{displayName || 'Пользователь'}
</Text>
<Text style={[base.tinyText, { color: theme.textMuted }]}>
{currentUser.email}
</Text>
</View>
</View>
<TouchableOpacity onPress={onLogout}>
<Text style={[base.smallText, { color: theme.danger }]}>Выйти</Text>
</TouchableOpacity>
</View>
<Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}>
Подписок учтено: {totalSubs} • PIN установлен:{' '}
{currentUser.pin ? 'да' : 'нет'}
</Text>
text<View style={{ marginTop: 12 }}> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} value={phone} onChangeText={setPhone} keyboardType="phone-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> {/* Бюджет и валюта */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Бюджет</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Установите доход, недельный бюджет и валюту — это нужно для индекса «финансовое здоровье» и рекомендаций. </Text> <TextInput placeholder="Доход в месяц" placeholderTextColor={theme.textMuted} value={monthlyIncome} onChangeText={setMonthlyIncome} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Недельный бюджет" placeholderTextColor={theme.textMuted} value={weeklyBudget} onChangeText={setWeeklyBudget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Валюта (например, ₸, ₽, $)" placeholderTextColor={theme.textMuted} value={currency} onChangeText={setCurrency} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TouchableOpacity onPress={handleSaveSettings} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить профиль и бюджет </Text> </TouchableOpacity> </View> {/* Тема */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Тема и вид</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 6 }]}> Тёмная тема </Text> <Switch value={theme.mode === 'dark'} onValueChange={onToggleTheme} thumbColor={theme.mode === 'dark' ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> В будущем сюда можно добавить иконки, виджеты и другие настройки UI. </Text> </View> {/* Анти-импульсная защита */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Контроль импульсных покупок</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧠 Эксперимент</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Включите анти-импульсный режим, чтобы приложение задавало вам «контрольный вопрос» перед крупной покупкой. </Text> <View style={[base.rowBetween, { marginTop: 8 }]}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> Анти-импульсный режим </Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Срабатывает при тратах выше порога. </Text> </View> <Switch value={antiImpulseEnabled} onValueChange={setAntiImpulseEnabled} thumbColor={antiImpulseEnabled ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> <TextInput placeholder="Порог крупной покупки (например, 50 000)" placeholderTextColor={theme.textMuted} value={antiImpulseThreshold} onChangeText={setAntiImpulseThreshold} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> </View> {/* Безопасная заметка */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Безопасная заметка</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔐 Локально</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Заметка шифруется простым алгоритмом (XOR + HEX) с вашим PIN и хранится только в AsyncStorage. Для реального проекта используйте надёжную криптографию (AES и т.п.) и биометрию. </Text> {!noteUnlocked && ( <> {currentUser.pin ? ( <> <TextInput placeholder="Введите PIN для разблокировки" placeholderTextColor={theme.textMuted} value={pinInput} onChangeText={setPinInput} keyboardType="number-pad" secureTextEntry style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleUnlockNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Разблокировать заметку </Text> </TouchableOpacity> </> ) : ( <> <Text style={[base.smallText, { color: theme.text, marginTop: 8 }]}> PIN не установлен при регистрации, поэтому заметка хранится без шифрования. </Text> <TouchableOpacity onPress={() => setNoteUnlocked(true)} style={[ base.button, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Показать заметку </Text> </TouchableOpacity> </> )} </> )} {noteUnlocked && ( <> <TextInput placeholder="Секретная финансовая заметка (пароли сюда лучше не писать)" placeholderTextColor={theme.textMuted} value={noteDraft} onChangeText={setNoteDraft} multiline style={[ base.input, { minHeight: 80, textAlignVertical: 'top', backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleSaveNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить зашифровано </Text> </TouchableOpacity> </> )} </View> </ScrollView>
);
};
const MainApp = ({
currentUser,
userData,
setUserData,
onLogout,
themeMode,
setThemeMode,
}) => {
const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME;
const [activeTab, setActiveTab] = useState('home');
const handleUpdateUserData = (updater) => {
setUserData((prev) => {
const prevVal = prev || createDefaultUserData(themeMode);
const next = typeof updater === 'function' ? updater(prevVal) : updater;
return next;
});
};
const handleToggleTheme = () => {
setThemeMode((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
const profile = userData.profile || {};
const avatarUri = profile.avatarUri;
const displayName =
profile.firstName || profile.lastName
? ${profile.firstName || ''} ${profile.lastName || ''}.trim()
: currentUser.email;
const initials =
profile.firstName || profile.lastName
? ${(profile.firstName || profile.lastName || 'U').charAt(0).toUpperCase()}
: currentUser.email.charAt(0).toUpperCase();
return (
<SafeAreaView
style={[
base.container,
{ backgroundColor: theme.background },
]}
>
<StatusBar
barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'}
/>
<View style={base.header}>
<View style={base.row}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={base.avatarImage} />
) : (
<View
style={[
base.avatarCircle,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.smallText, { color: theme.textMuted }]}>
{initials}
</Text>
</View>
)}
<View style={{ marginLeft: 10 }}>
<Text style={[base.headerTitle, { color: theme.text }]}>
Мой бюджет
</Text>
<Text style={[base.headerSubtitle, { color: theme.textMuted }]}>
{displayName}
</Text>
</View>
</View>
<View style={base.chipRow}>
<View
style={[
base.chip,
{ backgroundColor: theme.accentSoft },
]}
>
<Text style={[base.chipText, { color: theme.accent }]}>🤖 ИИ-чек</Text>
</View>
<TouchableOpacity
onPress={handleToggleTheme}
style={[
base.chip,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.chipText, { color: theme.text }]}>
{theme.mode === 'dark' ? '◐' : '◑'}
</Text>
</TouchableOpacity>
</View>
</View>
text{activeTab === 'home' && <HomeScreen userData={userData} theme={theme} />} {activeTab === 'expenses' && ( <ExpensesScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'goals' && ( <GoalsScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'ai' && <AiChatScreen userData={userData} theme={theme} />} {activeTab === 'profile' && ( <ProfileScreen currentUser={currentUser} userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} onLogout={onLogout} onToggleTheme={handleToggleTheme} /> )} <View style={[ base.tabBar, { backgroundColor: theme.card, borderTopColor: theme.border }, ]} > {[ { id: 'home', label: 'Главная', icon: '🏡' }, { id: 'expenses', label: 'Траты', icon: '💳' }, { id: 'goals', label: 'Цели', icon: '📌' }, { id: 'ai', label: 'ИИ-чат', icon: '🧠' }, { id: 'profile', label: 'Профиль', icon: '👤' }, ].map((tab) => { const isActive = activeTab === tab.id; return ( <TouchableOpacity key={tab.id} onPress={() => setActiveTab(tab.id)} style={[ base.tabButton, { backgroundColor: isActive ? theme.accentSoft : 'transparent' }, ]} > <Text style={[ { fontSize: 16 }, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.icon} </Text> <Text style={[ base.tabLabel, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.label} </Text> </TouchableOpacity> ); })} </View> </SafeAreaView>
);
};
// ---------- корневой компонент ----------
export default function App() {
const [users, setUsers] = useState([]);
const [session, setSession] = useState(null);
const [userData, setUserData] = useState(null);
const [themeMode, setThemeMode] = useState('dark');
const [loading, setLoading] = useState(true);
const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME;
useEffect(() => {
const init = async () => {
try {
const usersJson = await AsyncStorage.getItem(STORAGE_KEYS.USERS);
const sessionJson = await AsyncStorage.getItem(STORAGE_KEYS.SESSION);
const loadedUsers = usersJson ? JSON.parse(usersJson) : [];
setUsers(loadedUsers);
if (sessionJson) {
const s = JSON.parse(sessionJson);
const u = loadedUsers.find((user) => user.id === s.userId);
if (u) {
setSession(s);
const dataKey = FINAPP_DATA_${u.id};
const dataJson = await AsyncStorage.getItem(dataKey);
if (dataJson) {
const parsed = JSON.parse(dataJson);
setUserData(parsed);
if (parsed.settings && parsed.settings.themeMode) {
setThemeMode(parsed.settings.themeMode);
}
} else {
const defaultData = createDefaultUserData(themeMode);
setUserData(defaultData);
await AsyncStorage.setItem(dataKey, JSON.stringify(defaultData));
}
}
}
} catch (e) {
console.warn('Init error', e);
} finally {
setLoading(false);
}
};
init();
}, []);
useEffect(() => {
const persist = async () => {
if (!session || !userData) return;
try {
const key = FINAPP_DATA_${session.userId};
const payload = {
...userData,
settings: {
...(userData.settings || {}),
themeMode,
},
};
await AsyncStorage.setItem(key, JSON.stringify(payload));
} catch (e) {
console.warn('Save userData error', e);
}
};
persist();
}, [userData, session, themeMode]);
const handleLogin = async (user, profileFromRegistration = null) => {
const s = { userId: user.id };
setSession(s);
await AsyncStorage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(s));
const key = FINAPP_DATA_${user.id};
const dataJson = await AsyncStorage.getItem(key);
if (dataJson) {
const parsed = JSON.parse(dataJson);
setUserData(parsed);
if (parsed.settings && parsed.settings.themeMode) {
setThemeMode(parsed.settings.themeMode);
}
} else {
const defaultData = createDefaultUserData(themeMode, profileFromRegistration || {});
setUserData(defaultData);
await AsyncStorage.setItem(key, JSON.stringify(defaultData));
}
};
const handleRegister = async (newUser, profile) => {
const updatedUsers = [...users, newUser];
setUsers(updatedUsers);
await AsyncStorage.setItem(STORAGE_KEYS.USERS, JSON.stringify(updatedUsers));
await handleLogin(newUser, profile);
};
const handleLogout = async () => {
setSession(null);
setUserData(null);
await AsyncStorage.removeItem(STORAGE_KEYS.SESSION);
};
if (loading) {
return (
<SafeAreaView
style={[
base.container,
base.center,
{ backgroundColor: theme.background },
]}
>
<StatusBar
barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'}
/>
<Text style={[base.smallText, { color: theme.textMuted }]}>
Загрузка данных...
</Text>
</SafeAreaView>
);
}
if (!session || !userData) {
return (
<AuthScreen
users={users}
onLogin={handleLogin}
onRegister={handleRegister}
theme={theme}
/>
);
}
const currentUser = users.find((u) => u.id === session.userId) || {
id: session.userId,
email: 'user',
pin: '',
};
return (
<MainApp
currentUser={currentUser}
userData={userData}
setUserData={setUserData}
onLogout={handleLogout}
themeMode={themeMode}
setThemeMode={setThemeMode}
/>
);
}
Вот как сделать реальное OCR-распознавание суммы с чека, а не фиксированное 1235, и при этом использовать ML-Kit (то самое «искусственный интеллект» 😊).
Я покажу:
runOcrOnReceiptImage и parseReceiptOcrText.Остальной код приложения можешь оставить без изменений — просто подменить эти части.
Используем модуль expo-mlkit-ocr – это обёртка над Google ML Kit, которая отлично дружит с Expo и даёт функцию recognizeText(uri), возвращающую весь текст и блоки текста с чека. (Libraries.io)
Установи в проекте:
bashnpm install expo-mlkit-ocr # или yarn add expo-mlkit-ocr
Важно: это нативный модуль, в обычном Expo Go он не заработает. Нужен dev-client / EAS build.
В самом верху, рядом с другими импортами, добавь:
jsimport ExpoMlkitOcr from 'expo-mlkit-ocr';
Полный блок импортов должен выглядеть так (новая строка помечена комментом):
jsimport React, { useEffect, useState } from 'react'; import { SafeAreaView, View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView, FlatList, StatusBar, KeyboardAvoidingView, Platform, Switch, Alert, Image, ActivityIndicator, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as ImagePicker from 'expo-image-picker'; import * as Speech from 'expo-speech'; import ExpoMlkitOcr from 'expo-mlkit-ocr'; // 👈 новый импорт OCR
runOcrOnReceiptImage (реальный OCR + ИИ)Найди в коде блок:
js// ---------- OCR + ИИ-проверка чеков ---------- // Заглушка для OCR. Здесь вы подключаете Google ML Kit / Tesseract. const runOcrOnReceiptImage = async (uri) => { console.log('runOcrOnReceiptImage placeholder, uri:', uri); // TODO: заменить на реальный вызов Vision OCR // Пример: // const result = await MLKitTextRecognition.recognize(uri); // return result.text; // Сейчас вернём тестовый текст, чтобы отладить пайплайн. return 'Супермаркет Пример\nИТОГО 1234.56\nСпасибо за покупку'; };
Полностью замени его на:
js// ---------- OCR + ИИ-проверка чеков ---------- // Реальный OCR на базе Google ML Kit через expo-mlkit-ocr const runOcrOnReceiptImage = async (uri) => { try { console.log('runOcrOnReceiptImage: ML Kit OCR, uri:', uri); // expo-mlkit-ocr ожидает file:// uri и возвращает: // { text: string, blocks: [{ text, lines: [...], ... }, ...] } const result = await ExpoMlkitOcr.recognizeText(uri); if (result && typeof result.text === 'string' && result.text.trim().length > 0) { return result.text; } // На всякий случай собираем текст из блоков, если result.text пустой if (result && Array.isArray(result.blocks)) { return result.blocks .map((b) => b.text) .filter(Boolean) .join('\n'); } return ''; } catch (e) { console.warn('runOcrOnReceiptImage OCR error:', e); return ''; } };
Теперь при сканировании чека вместо тестовой строки реально вызывается ML-Kit и возвращается текст с фотографии.
parseReceiptOcrTextСейчас у тебя парсер достаточно простой и иногда может цепляться за «левые» цифры, а в заглушке всегда возвращался один и тот же текст (поэтому и виделась одна и та же сумма).
Мы сделаем более «умный» (условно ИИ-подобный) парсер, который:
ИТОГ, К ОПЛАТЕ, СУММА К ОПЛАТЕ, TOTAL.1 234,56).title) берёт из первой строки с буквами, которая не похожа на служебную (КАССИР, ЧЕК, ФИСКАЛЬНЫЙ и т.п.).Найди старую функцию:
jsconst parseReceiptOcrText = (text) => { if (!text) return null; const lines = text .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); if (!lines.length) return null; const normalized = text.replace(',', '.'); const matches = normalized.match(/(\d+[.]\d{2}|\d{3,})/g); let amount = 0; if (matches && matches.length) { const nums = matches .map((s) => parseFloat(s)) .filter((n) => Number.isFinite(n)); if (nums.length) { amount = Math.max(...nums); } } let titleLine = ''; for (const line of lines) { const lower = line.toLowerCase(); if ( /итог|итого|total|сумма|наличн|безнал|касса/.test(lower) || /^[\d\s.,]+$/.test(line) ) { continue; } titleLine = line; break; } if (!titleLine) { titleLine = 'Покупка по чеку'; } const category = guessCategory(titleLine); return { title: titleLine, amount, category, }; };
И замени её целиком на эту версию:
jsconst parseReceiptOcrText = (text) => { if (!text) return null; // Разбиваем на строки, убираем пустые const lines = text .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); if (!lines.length) return null; // Регулярка для денежного числа: // - поддерживает "1 234,56", "1234.56", "12 345", "12345" const MONEY_REGEX = /\d{1,3}(?:[ \u00A0]\d{3})*(?:[.,]\d{1,2})?|\d+[.,]\d{1,2}/g; const parseMoney = (raw) => { if (!raw) return NaN; let cleaned = String(raw) .replace(/\u00A0/g, ' ') // неразрывный пробел .replace(/[^\d.,\s]/g, '') // оставляем только цифры, пробелы, точки и запятые .replace(/\s+/g, ' ') .trim(); if (!cleaned) return NaN; // убираем пробелы-разделители тысяч cleaned = cleaned.replace(/\s/g, ''); // если есть и точка, и запятая — считаем, что запятая это разделитель копеек if (cleaned.includes(',') && cleaned.includes('.')) { const lastComma = cleaned.lastIndexOf(','); const lastDot = cleaned.lastIndexOf('.'); if (lastComma > lastDot) { cleaned = cleaned.replace(/\./g, '').replace(',', '.'); } else { cleaned = cleaned.replace(/,/g, ''); } } else if (cleaned.includes(',')) { // запятая как разделитель копеек cleaned = cleaned.replace(',', '.'); } const value = parseFloat(cleaned); return Number.isFinite(value) ? value : NaN; }; const isTotalLine = (line) => { const lower = line.toLowerCase().replace(/\s+/g, ' '); return ( /итог(о)?/.test(lower) || /к оплате/.test(lower) || /сумма к оплате/.test(lower) || /итог по чеку/.test(lower) || /total/.test(lower) ); }; let amount = null; // 1) Пробуем найти строку с ИТОГ/К ОПЛАТЕ и взять сумму оттуда for (const line of lines) { if (!isTotalLine(line)) continue; const matches = line.match(MONEY_REGEX); if (matches && matches.length) { const raw = matches[matches.length - 1]; // обычно нужная сумма — последняя в строке const v = parseMoney(raw); if (Number.isFinite(v) && v > 0) { amount = v; break; } } } // 2) Если не нашли — смотрим последние 3–5 строк (там часто итог) if (amount == null) { const tail = lines.slice(-5); const candidates = []; tail.forEach((line) => { const matches = line.match(MONEY_REGEX); if (!matches) return; matches.forEach((m) => { const v = parseMoney(m); if (Number.isFinite(v) && v > 0 && v < 1e7) { candidates.push(v); } }); }); if (candidates.length) { // Берём максимум — итог, как правило, больше цены единиц amount = Math.max(...candidates); } } // 3) Глобальный запасной вариант — ищем по всему тексту if (amount == null) { const candidates = []; lines.forEach((line) => { const matches = line.match(MONEY_REGEX); if (!matches) return; matches.forEach((m) => { const v = parseMoney(m); if (Number.isFinite(v) && v > 0 && v < 1e7) { candidates.push(v); } }); }); if (candidates.length) { amount = Math.max(...candidates); } } // 4) Определяем название магазина / покупки let titleLine = ''; for (const line of lines) { const letters = line.replace(/[^A-Za-zА-Яа-яЁё]/g, ''); if (!letters.length) continue; // чисто цифры / служебное if (isTotalLine(line)) continue; if (/кассир|смена|чек|фискальн|ккм|рнк|зн/i.test(line)) continue; titleLine = line; break; } if (!titleLine) { titleLine = 'Покупка по чеку'; } const category = guessCategory(titleLine); return { title: titleLine, amount: Number.isFinite(amount) && amount > 0 ? amount : 0, category, }; };
Нет больше заглушки, которая возвращала один и тот же текст.
Теперь runOcrOnReceiptImage реально дергает ML Kit и возвращает текст с конкретного фото чека.
parseReceiptOcrText больше не берёт просто «любое большое число», а целенаправленно ищет итоговую сумму:
ИТОГО, К ОПЛАТЕ, TOTAL),Если итог определить не удалось, amount будет 0, и твой обработчик handleScanReceipt покажет алерт:
jsif (!parsed || !parsed.amount || parsed.amount <= 0) { Alert.alert( 'Не удалось распознать чек', 'Попробуйте сделать фото поярче и ровнее, чтобы мы могли прочитать сумму.', ); return; }
То есть приложение явно скажет, что чек распознать не получилось, а не подставит случайное число.
Если хочешь — могу следующим шагом собрать для тебя полный App.js целиком уже с этими изменениями, но технически для исправления багов достаточно добавить импорт + заменить две функции как выше.
мне нужен полный код отправь полный исправленный код
мне нужен полный код отправь полный исправленный код
Вот полный файл App.js с уже встроенным реальным OCR через ML Kit и более умным парсером суммы с чека 👇
⚠️ Не забудь установить библиотеку:
bashnpm install expo-mlkit-ocr # или yarn add expo-mlkit-ocr
jsimport React, { useEffect, useState } from 'react'; import { SafeAreaView, View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView, FlatList, StatusBar, KeyboardAvoidingView, Platform, Switch, Alert, Image, ActivityIndicator, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as ImagePicker from 'expo-image-picker'; import * as Speech from 'expo-speech'; import ExpoMlkitOcr from 'expo-mlkit-ocr'; // 👈 OCR на базе Google ML Kit // ---------- константы ---------- const STORAGE_KEYS = { USERS: 'FINAPP_USERS', SESSION: 'FINAPP_SESSION', }; const DARK_THEME = { mode: 'dark', background: '#020617', card: '#020617', cardAlt: '#0B1220', accent: '#22C55E', accentSoft: 'rgba(34,197,94,0.22)', text: '#F9FAFB', textMuted: '#9CA3AF', border: '#1F2937', danger: '#EF4444', success: '#22C55E', warning: '#EAB308', }; const LIGHT_THEME = { mode: 'light', background: '#F3F4F6', card: '#FFFFFF', cardAlt: '#E5E7EB', accent: '#0F766E', accentSoft: 'rgba(15,118,110,0.16)', text: '#111827', textMuted: '#6B7280', border: '#D1D5DB', danger: '#DC2626', success: '#16A34A', warning: '#CA8A04', }; const DEFAULT_CURRENCY = '₸'; const DEFAULT_CATEGORIES = [ 'Еда', 'Транспорт', 'Жильё', 'Здоровье', 'Подписки', 'Игры/развлечения', 'Покупки', 'Сбережения', 'Другое', ]; // ---------- стили ---------- const base = StyleSheet.create({ flex1: { flex: 1 }, center: { justifyContent: 'center', alignItems: 'center' }, container: { flex: 1 }, authContainer: { flex: 1, paddingHorizontal: 24, paddingTop: 32, paddingBottom: 24 }, title: { fontSize: 28, fontWeight: '700', marginBottom: 4 }, subtitle: { fontSize: 14, marginBottom: 24 }, input: { borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, fontSize: 14, marginBottom: 12, }, button: { borderRadius: 18, paddingVertical: 12, alignItems: 'center', justifyContent: 'center', marginTop: 4, }, buttonText: { fontSize: 15, fontWeight: '600' }, link: { fontSize: 14, marginTop: 12, textAlign: 'center' }, errorText: { fontSize: 13, marginBottom: 8 }, header: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, headerTitle: { fontSize: 22, fontWeight: '700' }, headerSubtitle: { fontSize: 12 }, chipRow: { flexDirection: 'row', alignItems: 'center' }, chip: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999, marginLeft: 8, }, chipText: { fontSize: 12 }, content: { flex: 1, paddingHorizontal: 16, paddingTop: 4, paddingBottom: 4, }, card: { borderRadius: 20, padding: 16, marginBottom: 14, shadowColor: '#000', shadowOpacity: 0.14, shadowRadius: 12, shadowOffset: { width: 0, height: 4 }, elevation: 4, }, cardHeaderRow: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 6, }, cardTitle: { fontSize: 16, fontWeight: '600' }, cardSubtitle: { fontSize: 12 }, row: { flexDirection: 'row', alignItems: 'center' }, rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, pill: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, marginLeft: 6, }, pillText: { fontSize: 11, fontWeight: '500' }, tabBar: { flexDirection: 'row', paddingHorizontal: 6, paddingVertical: 8, borderTopWidth: StyleSheet.hairlineWidth, }, tabButton: { flex: 1, alignItems: 'center', paddingVertical: 8, borderRadius: 999, marginHorizontal: 4, }, tabLabel: { fontSize: 11, marginTop: 2 }, badgeDot: { width: 8, height: 8, borderRadius: 999, marginRight: 4, }, smallText: { fontSize: 12 }, tinyText: { fontSize: 11 }, sectionTitle: { fontSize: 14, fontWeight: '600', marginBottom: 8 }, inputRow: { flexDirection: 'row', alignItems: 'center', borderRadius: 14, paddingHorizontal: 10, marginBottom: 8, }, inputInline: { flex: 1, paddingVertical: 6, fontSize: 13 }, expenseItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10, }, divider: { height: StyleSheet.hairlineWidth, marginVertical: 6, }, tag: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, marginRight: 6, marginTop: 4, }, tagText: { fontSize: 11 }, chartRow: { marginTop: 6 }, chartBarBg: { height: 10, borderRadius: 999, overflow: 'hidden', }, chartBarFill: { height: 10, borderRadius: 999, }, chatBubble: { padding: 10, borderRadius: 14, marginVertical: 4, maxWidth: '85%', }, chatUser: { alignSelf: 'flex-end' }, chatAi: { alignSelf: 'flex-start' }, badge: { paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, marginRight: 6, marginBottom: 6, }, avatarCircle: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', }, avatarImage: { width: 44, height: 44, borderRadius: 22, }, goalImage: { width: '100%', height: 120, borderRadius: 12, marginTop: 6, }, }); // ---------- helpers ---------- const formatCurrency = (amount, currency = DEFAULT_CURRENCY) => { const n = Number.isFinite(amount) ? amount : 0; const rounded = Math.round(n); return `${currency} ${rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}`; }; const formatDate = (isoString) => { if (!isoString) return ''; const d = new Date(isoString); if (Number.isNaN(d.getTime())) return ''; return d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short', }); }; const getMonthKey = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; const guessCategory = (title) => { if (!title) return 'Другое'; const t = title.toLowerCase(); if (/(super|market|магазин|магнит|кошелек|grocery|food|еда|dodo|kfc|burger)/.test(t)) return 'Еда'; if (/(аптека|pharm|apteka|drug)/.test(t)) return 'Здоровье'; if (/(taxi|такси|yandex go|bolt|uber|bus|metro)/.test(t)) return 'Транспорт'; if (/(аренда|rent|ipotek|ипотека|квартира)/.test(t)) return 'Жильё'; if (/(netflix|spotify|youtube|yandex plus|подписка|subscription)/.test(t)) return 'Подписки'; if (/(steam|psn|xbox|game|игр)/.test(t)) return 'Игры/развлечения'; if (/(cafe|кафе|coffee|кофе|restaurant|ресторан)/.test(t)) return 'Еда'; if (/(ozon|wildberries|wb|aliexpress)/.test(t)) return 'Покупки'; return 'Другое'; }; const sumBy = (arr, fn) => arr.reduce((acc, item, idx) => acc + (fn(item, idx) || 0), 0); const buildMonthlyTotals = (expenses) => { const totals = {}; expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); const key = getMonthKey(d); totals[key] = (totals[key] || 0) + (Number(e.amount) || 0); }); return totals; }; const buildCategoryTotalsForMonth = (expenses, monthKey) => { const m = {}; expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); if (getMonthKey(d) !== monthKey) return; const cat = e.category || 'Другое'; m[cat] = (m[cat] || 0) + (Number(e.amount) || 0); }); return m; }; const forecastNextMonth = (expenses) => { const totalsMap = buildMonthlyTotals(expenses); const keys = Object.keys(totalsMap).sort(); if (keys.length === 0) return 0; const ys = keys.map((k) => totalsMap[k]); if (keys.length === 1) return ys[0]; const n = ys.length; let sumX = 0; let sumY = 0; let sumXY = 0; let sumX2 = 0; for (let i = 0; i < n; i += 1) { const x = i; const y = ys[i]; sumX += x; sumY += y; sumXY += x * y; sumX2 += x * x; } const denom = n * sumX2 - sumX * sumX; const slope = denom === 0 ? 0 : (n * sumXY - sumX * sumY) / denom; const intercept = (sumY - slope * sumX) / n; const forecast = intercept + slope * n; return forecast > 0 ? forecast : ys[ys.length - 1]; }; const detectAnomalies = (expenses) => { if (!expenses.length) return []; const amounts = expenses.map((e) => Number(e.amount) || 0); const avg = sumBy(amounts, (a) => a) / amounts.length; const threshold = Math.max(avg * 2, 0); return expenses.filter((e) => (Number(e.amount) || 0) > threshold && threshold > 0); }; const calculateDailyStreak = (expenses, monthlyIncome) => { if (!expenses.length || !monthlyIncome) return 0; const dailyBudget = monthlyIncome / 30; if (dailyBudget <= 0) return 0; const byDay = {}; expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); const key = d.toISOString().substring(0, 10); byDay[key] = (byDay[key] || 0) + (Number(e.amount) || 0); }); const today = new Date(); let streak = 0; for (let i = 0; i < 365; i += 1) { const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000); const key = d.toISOString().substring(0, 10); const spent = byDay[key] || 0; if (spent <= dailyBudget) { streak += 1; } else { break; } } return streak; }; const countNoSpendDaysThisMonth = (expenses) => { if (!expenses.length) return 0; const now = new Date(); const year = now.getFullYear(); const month = now.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const spentDays = new Set(); expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); if (d.getFullYear() !== year || d.getMonth() !== month) return; spentDays.add(d.getDate()); }); return daysInMonth - spentDays.size; }; const calculateFinancialHealth = (userData) => { const expenses = userData.expenses || []; const settings = userData.settings || {}; const monthlyIncome = Number(settings.monthlyIncome) || Number(userData.income) || 0; const now = new Date(); const currentMonthKey = getMonthKey(now); const monthTotals = buildMonthlyTotals(expenses); const totalThisMonth = monthTotals[currentMonthKey] || 0; const savings = Math.max(0, monthlyIncome - totalThisMonth); const savingsRate = monthlyIncome > 0 ? savings / monthlyIncome : 0; const unnecessary = sumBy(expenses, (e) => { if (e.type === 'income') return 0; const d = new Date(e.date || new Date()); if (getMonthKey(d) !== currentMonthKey) return 0; const c = (e.category || '').toLowerCase(); if ( c.includes('игр') || c.includes('развлеч') || c.includes('подпис') || c.includes('покуп') || c.includes('кафе') || c.includes('bar') || c.includes('club') ) { return Number(e.amount) || 0; } return 0; }); const unnecessaryShare = monthlyIncome > 0 ? unnecessary / monthlyIncome : 0; let score = 50; score += Math.min(20, savingsRate * 40); score -= Math.min(15, unnecessaryShare * 30); if (userData.goals && userData.goals.length) score += 5; if (expenses.length > 20) score += 5; score = Math.max(0, Math.min(100, Math.round(score))); let label = 'Нужно больше данных'; if (score >= 80) { label = 'Отличное состояние'; } else if (score >= 60) { label = 'Хорошее состояние'; } else if (score >= 40) { label = 'Среднее, есть над чем поработать'; } else { label = 'Слабое, нужен план'; } return { score, label, monthlyIncome, totalThisMonth, savings, unnecessary, }; }; // простое шифрование заметок по PIN (XOR + hex, не для продакшена) const encryptNote = (plain, pin) => { if (!plain) return ''; if (!pin) return plain; const digits = pin.split('').map((d) => parseInt(d, 10) || 0); if (!digits.length) return plain; let out = ''; for (let i = 0; i < plain.length; i += 1) { const code = plain.charCodeAt(i) ^ digits[i % digits.length]; out += code.toString(16).padStart(2, '0'); } return out; }; const decryptNote = (cipher, pin) => { if (!cipher) return ''; if (!pin) return cipher; const digits = pin.split('').map((d) => parseInt(d, 10) || 0); if (!digits.length) return cipher; try { let out = ''; for (let i = 0; i < cipher.length; i += 2) { const hex = cipher.substr(i, 2); const code = parseInt(hex, 16) ^ digits[(i / 2) % digits.length]; out += String.fromCharCode(code); } return out; } catch (e) { return ''; } }; const buildSmartReminders = (userData) => { const reminders = []; const expenses = userData.expenses || []; const settings = userData.settings || {}; const now = new Date(); const nowKey = getMonthKey(now); const last7Total = sumBy(expenses, (e) => { if (e.type === 'income') return 0; const d = new Date(e.date || new Date()); const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24); if (diff >= 0 && diff <= 7) { return Number(e.amount) || 0; } return 0; }); const weeklyBudget = Number(settings.weeklyBudget) || 0; if (weeklyBudget > 0 && last7Total > weeklyBudget) { reminders.push( `За последние 7 дней вы уже вышли за недельный бюджет на ${formatCurrency( last7Total - weeklyBudget, settings.currency || DEFAULT_CURRENCY, )}.`, ); } else if (weeklyBudget > 0 && last7Total > weeklyBudget * 0.8) { reminders.push('Вы приблизились к недельному бюджету — самое время притормозить расходы.'); } (userData.goals || []).forEach((g) => { const target = Number(g.targetAmount) || 0; const saved = Number(g.savedAmount) || 0; if (!target) return; const d = g.deadline ? new Date(g.deadline) : null; const left = target - saved; if (left <= 0 || !d) return; const daysLeft = Math.round((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); if (daysLeft <= 0) return; const perDay = left / daysLeft; if (perDay > 0) { reminders.push( `До цели «${g.title}» осталось ${formatCurrency( left, settings.currency || DEFAULT_CURRENCY, )}. Нужно откладывать примерно ${formatCurrency( perDay, settings.currency || DEFAULT_CURRENCY, )} в день.`, ); } }); const monthTotals = buildMonthlyTotals(expenses); const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); const prevKey = getMonthKey(prevMonth); const totalNow = monthTotals[nowKey] || 0; const totalPrev = monthTotals[prevKey] || 0; if (totalPrev > 0 && totalNow > totalPrev * 1.15) { const diff = totalNow - totalPrev; reminders.push( `В этом месяце ваши траты уже на ${formatCurrency( diff, settings.currency || DEFAULT_CURRENCY, )} больше, чем в прошлом.`, ); } if (!reminders.length) { reminders.push('Вы молодец — критичных напоминаний сейчас нет 🎉'); } return reminders; }; const buildInsightsSummary = (userData) => { const expenses = userData.expenses || []; const settings = userData.settings || {}; const now = new Date(); const currentMonthKey = getMonthKey(now); const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); const prevKey = getMonthKey(prevMonth); const monthTotals = buildMonthlyTotals(expenses); const totalNow = monthTotals[currentMonthKey] || 0; const totalPrev = monthTotals[prevKey] || 0; const catNow = buildCategoryTotalsForMonth(expenses, currentMonthKey); const catPrev = buildCategoryTotalsForMonth(expenses, prevKey); const entriesNow = Object.entries(catNow); const topCat = entriesNow.sort((a, b) => b[1] - a[1])[0]; const currency = settings.currency || DEFAULT_CURRENCY; const anomalies = detectAnomalies(expenses).slice(0, 3); const lines = []; if (totalPrev > 0 && totalNow > 0) { const delta = totalNow - totalPrev; const pct = (delta / totalPrev) * 100; if (Math.abs(pct) >= 5) { if (pct > 0) { lines.push( `Общие расходы выросли на ${pct.toFixed(1)}% по сравнению с прошлым месяцем (${formatCurrency( totalNow, currency, )} против ${formatCurrency(totalPrev, currency)}).`, ); } else { lines.push( `Вы тратите на ${Math.abs(pct).toFixed( 1, )}% меньше, чем в прошлом месяце — отличный прогресс 👏`, ); } } } if (topCat) { lines.push( `Больше всего вы тратите на категорию «${topCat[0]}» — ${formatCurrency( topCat[1], currency, )} в этом месяце.`, ); } Object.keys(catNow).forEach((c) => { const nowVal = catNow[c]; const prevVal = catPrev[c] || 0; if (prevVal <= 0) return; const pct = (nowVal - prevVal) / prevVal; if (pct >= 0.25 && nowVal > 0) { lines.push( `Категория «${c}» выросла на ${Math.round( pct * 100, )}% относительно прошлого месяца — стоит проверить, можно ли там сэкономить.`, ); } }); if (anomalies.length) { const txt = anomalies .map((a) => `"${a.title}" на ${formatCurrency(a.amount, currency)}`) .join(', '); lines.push(`Обнаружены разовые крупные траты: ${txt}. Убедитесь, что они действительно нужны.`); } if (!lines.length) { lines.push('Пока недостаточно данных для глубокого анализа. Начните добавлять траты 🙂'); } return lines; }; const generateAiAnswer = (promptRaw, userData) => { const prompt = promptRaw.toLowerCase(); const health = calculateFinancialHealth(userData); const insights = buildInsightsSummary(userData); const currency = (userData.settings && userData.settings.currency) || DEFAULT_CURRENCY; const expenses = userData.expenses || []; const monthlyTotals = buildMonthlyTotals(expenses); const nowKey = getMonthKey(new Date()); const totalThisMonth = monthlyTotals[nowKey] || 0; if (!expenses.length && !prompt.includes('регистрац')) { return 'У меня пока нет ваших данных. Добавьте несколько трат и доходов — тогда я смогу давать персональные советы.'; } if (prompt.includes('сократить') || prompt.includes('эконом') || prompt.includes('сэкономить')) { const catNow = buildCategoryTotalsForMonth(expenses, nowKey); const entries = Object.entries(catNow).sort((a, b) => b[1] - a[1]); const top3 = entries.slice(0, 3); const tips = top3 .map( ([c, v]) => `• «${c}» — ${formatCurrency( v, currency, )} в месяц. Попробуйте снизить хотя бы на 10–20%.`, ) .join('\n'); return ( `Сейчас ваши траты за месяц: ${formatCurrency( totalThisMonth, currency, )}. Чтобы сократить их, лучше всего начать с самых крупных категорий:\n\n${tips}\n\n` + 'Дополнительно:\n' + '• установите недельный бюджет и фиксируйте все траты;\n' + '• избегайте спонтанных покупок — делайте «паузу 24 часа»;\n' + '• для подписок — оставьте только то, чем пользуетесь каждую неделю.' ); } if (prompt.includes('как распределить') || prompt.includes('зарплат')) { const match = promptRaw.match(/(\d[\d\s]{2,})/); const income = match ? parseInt(match[1].replace(/\s/g, ''), 10) : health.monthlyIncome || 0; if (!income) { return 'Напишите, пожалуйста, размер зарплаты (например: «Как распределить зарплату 300 000?»), или задайте месячный доход в настройках. Тогда я предложу детальный план распределения.'; } const needs = income * 0.5; const wants = income * 0.3; const saving = income * 0.2; return ( `Классическая модель 50/30/20 для зарплаты ${formatCurrency(income, currency)}:\n\n` + `• 50% (${formatCurrency(needs, currency)}) — обязательные расходы (жильё, еда, транспорт, кредиты);\n` + `• 30% (${formatCurrency(wants, currency)}) — «хотелки» и развлечения;\n` + `• 20% (${formatCurrency(saving, currency)}) — сбережения и цели.\n\n` + 'Вы можете адаптировать: если есть кредиты — уменьшите «хотелки» до 10–15% и направьте разницу на досрочное погашение и накопления.' ); } if (prompt.includes('стоит ли покупать') || prompt.includes('покупать')) { return ( 'Задайте себе три вопроса перед покупкой:\n' + '1) Нужна ли вещь прямо сейчас или это импульс?\n' + '2) Сколько часов вашей работы она стоит?\n' + '3) Через месяц вы будете так же рады этой покупке?\n\n' + 'Если сумма покупки >10% от месячного дохода — хорошо бы выдержать «паузу 24 часа». ' + 'Также посмотрите, не мешает ли эта покупка вашим финансовым целям (накоплениям и т.п.).' ); } if (prompt.includes('что если') || prompt.includes('если буду')) { return ( 'Функция «Что если?» уже доступна на вкладке «Цели» — там можно смоделировать, что будет, если сократить траты на кафе или начать откладывать больше. ' + 'Если хотите, сформулируйте сценарий конкретно (например: «Что будет, если каждый месяц откладывать на 20 000 больше?»), и я прикину эффект в чате.' ); } const baseInsights = insights.slice(0, 2).join('\n\n'); return ( `Краткий обзор вашего финансового состояния:\n` + `• Индекс финансового здоровья: ${health.score}/100 (${health.label}).\n` + `• Траты в этом месяце: ${formatCurrency(totalThisMonth, currency)}.\n` + `• Потенциальные сбережения: ${formatCurrency(health.savings, currency)}.\n\n` + `${baseInsights}\n\n` + 'Задавайте мне конкретные вопросы: например, «Как сократить расходы на 20%?» или ' + '«Помоги перераспределить бюджет с учётом цели на 800 000».' ); }; // helper для дефолтных данных пользователя const createDefaultUserData = (themeMode, profile = {}) => ({ income: 0, expenses: [], goals: [], subscriptions: [], settings: { monthlyIncome: 0, weeklyBudget: 0, currency: DEFAULT_CURRENCY, antiImpulseEnabled: false, antiImpulseThreshold: 0, themeMode: themeMode || 'dark', }, profile, secureNote: '', }); // helper для выбора картинки из галереи const pickImageFromLibrary = async () => { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { Alert.alert('Нет доступа', 'Разрешите доступ к фото в настройках устройства.'); return null; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8, }); if (result.canceled) return null; const asset = result.assets && result.assets[0]; if (!asset) return null; return asset.uri; }; // ---------- OCR + ИИ-проверка чеков ---------- // Реальный OCR на базе Google ML Kit через expo-mlkit-ocr const runOcrOnReceiptImage = async (uri) => { try { console.log('runOcrOnReceiptImage: ML Kit OCR, uri:', uri); // Библиотека expo-mlkit-ocr сейчас официально поддерживает только Android if (Platform.OS !== 'android') { console.warn('expo-mlkit-ocr официально поддерживает только Android. На iOS может не работать.'); } // recognizeText(uri) -> { text: string, blocks: [...] } const result = await ExpoMlkitOcr.recognizeText(uri); if (result && typeof result.text === 'string' && result.text.trim().length > 0) { return result.text; } if (result && Array.isArray(result.blocks)) { return result.blocks .map((b) => b.text) .filter(Boolean) .join('\n'); } return ''; } catch (e) { console.warn('runOcrOnReceiptImage OCR error:', e); return ''; } }; // «Умный» парсер текста чека: пытается понять именно итоговую сумму const parseReceiptOcrText = (text) => { if (!text) return null; const lines = text .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); if (!lines.length) return null; // Денежный паттерн: 1 234,56 / 1234.56 / 12 345 / 12345 и т.п. const MONEY_REGEX = /\d{1,3}(?:[ \u00A0]\d{3})*(?:[.,]\d{1,2})?|\d+[.,]\d{1,2}/g; const parseMoney = (raw) => { if (!raw) return NaN; let cleaned = String(raw) .replace(/\u00A0/g, ' ') .replace(/[^\d.,\s]/g, '') .replace(/\s+/g, ' ') .trim(); if (!cleaned) return NaN; cleaned = cleaned.replace(/\s/g, ''); if (cleaned.includes(',') && cleaned.includes('.')) { const lastComma = cleaned.lastIndexOf(','); const lastDot = cleaned.lastIndexOf('.'); if (lastComma > lastDot) { cleaned = cleaned.replace(/\./g, '').replace(',', '.'); } else { cleaned = cleaned.replace(/,/g, ''); } } else if (cleaned.includes(',')) { cleaned = cleaned.replace(',', '.'); } const value = parseFloat(cleaned); return Number.isFinite(value) ? value : NaN; }; const isTotalLine = (line) => { const lower = line.toLowerCase().replace(/\s+/g, ' '); return ( /итог(о)?/.test(lower) || /к оплате/.test(lower) || /сумма к оплате/.test(lower) || /итог по чеку/.test(lower) || /total/.test(lower) ); }; let amount = null; // 1) Ищем строку с ИТОГ / К ОПЛАТЕ и берём сумму оттуда for (const line of lines) { if (!isTotalLine(line)) continue; const matches = line.match(MONEY_REGEX); if (matches && matches.length) { const raw = matches[matches.length - 1]; const v = parseMoney(raw); if (Number.isFinite(v) && v > 0) { amount = v; break; } } } // 2) Если не нашли — смотрим последние 3–5 строк if (amount == null) { const tail = lines.slice(-5); const candidates = []; tail.forEach((line) => { const matches = line.match(MONEY_REGEX); if (!matches) return; matches.forEach((m) => { const v = parseMoney(m); if (Number.isFinite(v) && v > 0 && v < 1e7) { candidates.push(v); } }); }); if (candidates.length) { amount = Math.max(...candidates); } } // 3) Совсем fallback — ищем по всему тексту if (amount == null) { const candidates = []; lines.forEach((line) => { const matches = line.match(MONEY_REGEX); if (!matches) return; matches.forEach((m) => { const v = parseMoney(m); if (Number.isFinite(v) && v > 0 && v < 1e7) { candidates.push(v); } }); }); if (candidates.length) { amount = Math.max(...candidates); } } // 4) Название магазина / покупки let titleLine = ''; for (const line of lines) { const letters = line.replace(/[^A-Za-zА-Яа-яЁё]/g, ''); if (!letters.length) continue; if (isTotalLine(line)) continue; if (/кассир|смена|чек|фискальн|ккм|рнк|зн/i.test(line)) continue; titleLine = line; break; } if (!titleLine) { titleLine = 'Покупка по чеку'; } const category = guessCategory(titleLine); return { title: titleLine, amount: Number.isFinite(amount) && amount > 0 ? amount : 0, category, }; }; // ---------- небольшие компоненты ---------- const CategoryBarChart = ({ data, theme }) => { if (!data || !data.length) return null; const max = Math.max(...data.map((d) => d.value || 0)); if (!max) return null; return ( <View style={base.chartRow}> {data.map((item) => { const pct = (item.value / max) * 100; return ( <View key={item.label} style={{ marginBottom: 6 }}> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted }]}>{item.label}</Text> <Text style={[base.tinyText, { color: theme.text }]}> {formatCurrency(item.value)} </Text> </View> <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 3 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accent, width: `${pct}%`, }, ]} /> </View> </View> ); })} </View> ); }; const SimpleMonthlyChart = ({ expenses, theme }) => { const totalsMap = buildMonthlyTotals(expenses || []); const keys = Object.keys(totalsMap).sort(); if (!keys.length) return null; const items = keys.slice(-6).map((k) => ({ key: k, value: totalsMap[k] })); const max = Math.max(...items.map((i) => i.value)); if (!max) return null; return ( <View style={{ marginTop: 4 }}> {items.map((item) => { const [year, month] = item.key.split('-'); const label = `${month}.${year.slice(-2)}`; const pct = (item.value / max) * 100; return ( <View key={item.key} style={{ marginBottom: 4 }}> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted }]}>{label}</Text> <Text style={[base.tinyText, { color: theme.text }]}> {formatCurrency(item.value)} </Text> </View> <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 3 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accentSoft, width: `${pct}%`, }, ]} /> </View> </View> ); })} </View> ); }; const SpendingGauge = ({ monthlyIncome, spent, theme, currency }) => { if (!monthlyIncome) return null; const ratio = monthlyIncome > 0 ? spent / monthlyIncome : 0; const pct = Math.max(0, Math.min(1, ratio)); const labelPct = (pct * 100).toFixed(0); return ( <View style={{ marginTop: 10 }}> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Расходы от дохода</Text> <Text style={[base.tinyText, { color: theme.text }]}> {labelPct}% ({formatCurrency(spent, currency)}) </Text> </View> <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 4, height: 12 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: ratio > 1 ? theme.danger : theme.accent, width: `${pct * 100}%`, }, ]} /> </View> {ratio > 1 && ( <Text style={[base.tinyText, { color: theme.danger, marginTop: 4 }]}> Вы вышли за рамки месячного дохода — стоит пересмотреть план. </Text> )} </View> ); }; // ---------- экраны ---------- const AuthScreen = ({ users, onLogin, onRegister, theme }) => { const [mode, setMode] = useState('login'); // 'login' | 'register' const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [pin, setPin] = useState(''); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [phone, setPhone] = useState(''); const [birthDate, setBirthDate] = useState(''); const [avatarUri, setAvatarUri] = useState(''); const [error, setError] = useState(''); const toggleMode = () => { setError(''); setMode((m) => (m === 'login' ? 'register' : 'login')); }; const handleLoginPress = () => { setError(''); const normalizedEmail = email.trim().toLowerCase(); if (!normalizedEmail || !password) { setError('Заполните email и пароль.'); return; } const user = users.find((u) => u.email === normalizedEmail); if (!user || user.password !== password) { setError('Неверный email или пароль.'); return; } onLogin(user); }; const handleRegisterPress = () => { setError(''); const normalizedEmail = email.trim().toLowerCase(); if (!normalizedEmail || !password) { setError('Заполните email и пароль.'); return; } if (!normalizedEmail.includes('@')) { setError('Некорректный email.'); return; } if (password.length < 4) { setError('Пароль должен быть не короче 4 символов.'); return; } if (!firstName.trim()) { setError('Укажите имя.'); return; } if (!phone.trim()) { setError('Укажите номер телефона.'); return; } if (users.some((u) => u.email === normalizedEmail)) { setError('Пользователь с таким email уже существует.'); return; } const newUser = { id: `u_${Date.now()}`, email: normalizedEmail, password, pin: pin || '', createdAt: new Date().toISOString(), }; const profile = { firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), birthDate: birthDate.trim(), avatarUri: avatarUri || '', }; onRegister(newUser, profile); }; const handlePickAvatar = async () => { const uri = await pickImageFromLibrary(); if (uri) { setAvatarUri(uri); } }; return ( <SafeAreaView style={[ base.container, { backgroundColor: theme.background }, ]} > <StatusBar barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'} /> <KeyboardAvoidingView style={base.flex1} behavior={Platform.OS === 'ios' ? 'padding' : undefined} > <View style={[base.authContainer]}> <View style={{ marginBottom: 32 }}> <Text style={[base.title, { color: theme.text }]}> Финансовый ИИ-помощник </Text> <Text style={[base.subtitle, { color: theme.textMuted }]}> Управляй доходами, расходами, целями и сканируй чеки с ИИ. </Text> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[ base.cardTitle, { color: theme.text }, ]} > {mode === 'login' ? 'Вход' : 'Регистрация'} </Text> <View style={[ base.pill, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.pillText, { color: theme.accent }, ]} > ⚡ ИИ + чек-сканер </Text> </View> </View> {error ? ( <Text style={[base.errorText, { color: theme.danger }]}>{error}</Text> ) : null} {mode === 'register' && ( <> <View style={base.row}> <TouchableOpacity onPress={handlePickAvatar}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> + </Text> </View> )} </TouchableOpacity> <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото профиля (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте аватар, чтобы приложение стало более личным. </Text> </View> </View> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} keyboardType="phone-pad" value={phone} onChangeText={setPhone} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </> )} <TextInput placeholder="Email" placeholderTextColor={theme.textMuted} autoCapitalize="none" keyboardType="email-address" value={email} onChangeText={setEmail} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Пароль" placeholderTextColor={theme.textMuted} secureTextEntry value={password} onChangeText={setPassword} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> {mode === 'register' && ( <TextInput placeholder="PIN (необязательно, для шифрования заметок)" placeholderTextColor={theme.textMuted} value={pin} onChangeText={setPin} keyboardType="number-pad" maxLength={6} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> )} <TouchableOpacity onPress={mode === 'login' ? handleLoginPress : handleRegisterPress} style={[ base.button, { backgroundColor: theme.accent, marginTop: 8 }, ]} > <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {mode === 'login' ? 'Войти' : 'Зарегистрироваться'} </Text> </TouchableOpacity> <TouchableOpacity onPress={toggleMode}> <Text style={[ base.link, { color: theme.textMuted }, ]} > {mode === 'login' ? 'Нет аккаунта? Создать' : 'Уже есть аккаунт? Войти'} </Text> </TouchableOpacity> </View> <View style={{ marginTop: 24 }}> <Text style={[ base.smallText, { color: theme.textMuted }, ]} > Данные хранятся только локально на устройстве (AsyncStorage). Для реального приложения стоит добавить серверный бэкап и более серьёзную криптографию. </Text> </View> </View> </KeyboardAvoidingView> </SafeAreaView> ); }; const HomeScreen = ({ userData, theme }) => { const health = calculateFinancialHealth(userData); const insights = buildInsightsSummary(userData); const reminders = buildSmartReminders(userData); const expenses = userData.expenses || []; const settings = userData.settings || {}; const currency = settings.currency || DEFAULT_CURRENCY; const now = new Date(); const currentMonthKey = getMonthKey(now); const catNowMap = buildCategoryTotalsForMonth(expenses, currentMonthKey); const categoryData = Object.entries(catNowMap) .map(([label, value]) => ({ label, value })) .sort((a, b) => b.value - a.value); const forecast = forecastNextMonth(expenses); const streak = calculateDailyStreak( expenses, Number(settings.monthlyIncome) || Number(userData.income) || 0, ); const noSpendDays = countNoSpendDaysThisMonth(expenses); const profile = userData.profile || {}; const firstName = profile.firstName || ''; const badges = []; if (health.score >= 80) badges.push('Экономист месяца'); if (streak >= 7) badges.push('7 дней в плюсе'); if (noSpendDays >= 5) badges.push('Дни без трат'); if (expenses.length >= 50) badges.push('Финансовый самурай'); return ( <ScrollView style={[ base.content, { backgroundColor: theme.background }, ]} showsVerticalScrollIndicator={false} > <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.rowBetween}> <View style={{ flex: 1 }}> <Text style={[base.cardSubtitle, { color: theme.textMuted }]}> Индекс финансового здоровья </Text> <Text style={[ { fontSize: 32, fontWeight: '700', marginTop: 4 }, { color: theme.text }, ]} > {health.score} <Text style={{ fontSize: 16 }}>/100</Text> </Text> {firstName ? ( <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 4 }, ]} > {firstName}, это ваш персональный индекс. </Text> ) : null} </View> <View style={[ { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 16, }, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.tinyText, { color: theme.textMuted }]}>Статус</Text> <Text style={[ base.smallText, { color: theme.text, fontWeight: '600', marginTop: 2 }, ]} > {health.label} </Text> </View> </View> <View style={{ marginTop: 10 }}> <View style={base.rowBetween}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Доход / мес.</Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {health.monthlyIncome ? formatCurrency(health.monthlyIncome, currency) : 'Не задан'} </Text> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Траты в этом месяце </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(health.totalThisMonth, currency)} </Text> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Сбережения</Text> <Text style={[ base.smallText, { color: health.savings >= 0 ? theme.success : theme.danger, marginTop: 2, }, ]} > {formatCurrency(health.savings, currency)} </Text> </View> <SpendingGauge monthlyIncome={health.monthlyIncome} spent={health.totalThisMonth} theme={theme} currency={currency} /> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Привычки</Text> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Серия дней в рамках бюджета: {streak} </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Дней без трат в этом месяце: {noSpendDays} </Text> </View> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Бейджи</Text> <View style={[base.row, { flexWrap: 'wrap', marginTop: 4 }]}> {badges.length ? ( badges.map((b) => ( <View key={b} style={[ base.badge, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.tinyText, { color: theme.accent }, ]} > ✨ {b} </Text> </View> )) ) : ( <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Собирайте награды, держась в рамках бюджета. </Text> )} </View> </View> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>AI-инсайты</Text> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>🧩 Анализ</Text> </View> </View> {insights.slice(0, 3).map((line, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{line}</Text> </View> ))} </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Категории</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Текущий месяц </Text> </View> <CategoryBarChart data={categoryData} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Динамика</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Прогноз: {formatCurrency(forecast, currency)} </Text> </View> <SimpleMonthlyChart expenses={expenses} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Умные напоминания</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔔</Text> </View> {reminders.map((r, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{r}</Text> </View> ))} </View> </ScrollView> ); }; const ExpensesScreen = ({ userData, onUpdateUserData, theme }) => { const [title, setTitle] = useState(''); const [amount, setAmount] = useState(''); const [category, setCategory] = useState(''); const [isIncome, setIsIncome] = useState(false); const [isScanning, setIsScanning] = useState(false); const [lastReceiptPreview, setLastReceiptPreview] = useState(null); // { uri, text } const expenses = [...(userData.expenses || [])].sort( (a, b) => new Date(b.date || 0).getTime() - new Date(a.date || 0).getTime(), ); const settings = userData.settings || {}; const currency = settings.currency || DEFAULT_CURRENCY; const antiImpulseEnabled = settings.antiImpulseEnabled; const antiImpulseThreshold = Number(settings.antiImpulseThreshold) || 0; const addEntry = (n, guessed, customTitle, extraMeta) => { const entry = { id: `exp_${Date.now()}`, title: (customTitle || title).trim(), amount: n, category: guessed, type: isIncome ? 'income' : 'expense', date: new Date().toISOString(), ...(extraMeta ? { meta: extraMeta } : {}), }; onUpdateUserData((prev) => ({ ...prev, expenses: [...(prev.expenses || []), entry], })); setTitle(''); setAmount(''); setCategory(''); setIsIncome(false); }; const handleAdd = () => { const n = parseFloat(String(amount).replace(',', '.')); if (!title || !Number.isFinite(n) || n <= 0) { Alert.alert('Ошибка', 'Введите название и сумму.'); return; } const guessed = category || guessCategory(title); if (!isIncome && antiImpulseEnabled && antiImpulseThreshold > 0 && n >= antiImpulseThreshold) { Alert.alert( 'Анти-импульсная защита', `Покупка на ${formatCurrency( n, currency, )} превышает ваш лимит в ${formatCurrency(antiImpulseThreshold, currency)}.\n\n` + 'Вы уверены, что хотите продолжить?', [ { text: 'Отменить', style: 'cancel' }, { text: 'Купить', style: 'destructive', onPress: () => addEntry(n, guessed), }, ], ); return; } addEntry(n, guessed); }; const handleScanReceipt = async () => { try { setIsScanning(true); const perm = await ImagePicker.requestCameraPermissionsAsync(); if (!perm.granted) { Alert.alert('Нет доступа', 'Разрешите доступ к камере в настройках устройства.'); return; } const result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8, }); if (result.canceled) return; const asset = result.assets && result.assets[0]; if (!asset) return; const uri = asset.uri; const ocrText = await runOcrOnReceiptImage(uri); setLastReceiptPreview({ uri, text: ocrText }); const parsed = parseReceiptOcrText(ocrText); if (!parsed || !parsed.amount || parsed.amount <= 0) { Alert.alert( 'Не удалось распознать чек', 'Попробуйте сделать фото поярче и ровнее, чтобы мы могли прочитать сумму.', ); return; } const guessedCategory = parsed.category || guessCategory(parsed.title); const n = parsed.amount; // Авто-добавление в расходы onUpdateUserData((prev) => ({ ...prev, expenses: [ ...(prev.expenses || []), { id: `exp_receipt_${Date.now()}`, title: parsed.title || 'Покупка по чеку', amount: n, category: guessedCategory, type: 'expense', date: new Date().toISOString(), meta: { source: 'receipt', ocrText, imageUri: uri, }, }, ], })); Alert.alert( 'Чек добавлен', `Мы распознали сумму ${formatCurrency( n, currency, )} и категорию «${guessedCategory}».\nЗапись добавлена в историю расходов.`, ); } catch (e) { console.warn('Receipt scan error', e); Alert.alert('Ошибка', 'Не удалось обработать чек. Попробуйте ещё раз.'); } finally { setIsScanning(false); } }; const totalExpenses = sumBy(expenses, (e) => e.type === 'income' ? 0 : Number(e.amount) || 0, ); const totalIncome = sumBy(expenses, (e) => e.type === 'income' ? Number(e.amount) || 0 : 0, ); return ( <View style={[ base.content, { backgroundColor: theme.background }, ]} > <ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > {/* ИИ-проверка чеков */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <View style={{ flex: 1, paddingRight: 6 }}> <Text style={[base.cardTitle, { color: theme.text }]}> ИИ-проверка чеков </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 2 }]}> Сделайте фото чека — мы распознаем сумму и автоматически добавим расход. </Text> </View> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>🤖 Чек-сканер</Text> </View> </View> <TouchableOpacity onPress={handleScanReceipt} disabled={isScanning} style={[ base.button, { backgroundColor: isScanning ? theme.cardAlt : theme.accent, marginTop: 8, flexDirection: 'row', }, ]} > {isScanning && ( <ActivityIndicator size="small" style={{ marginRight: 8 }} color="#FFFFFF" /> )} <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {isScanning ? 'Сканируем чек...' : 'Сканировать чек с ИИ'} </Text> </TouchableOpacity> {lastReceiptPreview && ( <View style={[base.row, { marginTop: 10 }]}> <Image source={{ uri: lastReceiptPreview.uri }} style={{ width: 56, height: 56, borderRadius: 12, marginRight: 8 }} /> <View style={{ flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Последний чек (OCR) </Text> <Text numberOfLines={3} style={[base.smallText, { color: theme.text, marginTop: 2 }]} > {lastReceiptPreview.text || 'Текст не распознан'} </Text> </View> </View> )} </View> {/* Ручное добавление записи */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Новая запись</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}> Доход </Text> <Switch value={isIncome} onValueChange={setIsIncome} thumbColor={isIncome ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Магазин / описание" placeholderTextColor={theme.textMuted} value={title} onChangeText={(text) => { setTitle(text); if (!category) { setCategory(guessCategory(text)); } }} style={[base.inputInline, { color: theme.text }]} /> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} keyboardType="numeric" value={amount} onChangeText={setAmount} style={[base.inputInline, { color: theme.text }]} /> <Text style={[base.smallText, { color: theme.textMuted }]}>{currency}</Text> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Категория</Text> <ScrollView horizontal showsHorizontalScrollIndicator={false}> {DEFAULT_CATEGORIES.map((c) => ( <TouchableOpacity key={c} onPress={() => setCategory(c)} style={[ base.tag, { backgroundColor: category === c ? theme.accentSoft : theme.cardAlt, borderWidth: 1, borderColor: category === c ? theme.accent : theme.border, }, ]} > <Text style={[ base.tagText, { color: category === c ? theme.accent : theme.textMuted, }, ]} > {c} </Text> </TouchableOpacity> ))} </ScrollView> </View> <TouchableOpacity onPress={handleAdd} style={[ base.button, { backgroundColor: theme.accent, marginTop: 12 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить </Text> </TouchableOpacity> </View> {/* Итоги */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.rowBetween}> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего расходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalExpenses, currency)} </Text> </View> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего доходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalIncome, currency)} </Text> </View> </View> </View> {/* История */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>История</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> {expenses.length} записей </Text> </View> {expenses.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Добавьте первую трату или доход. </Text> ) : ( <FlatList data={expenses} keyExtractor={(item) => item.id} scrollEnabled={false} renderItem={({ item }) => { const isImpulseHighlight = item.type !== 'income' && antiImpulseEnabled && antiImpulseThreshold > 0 && Number(item.amount) >= antiImpulseThreshold; const isReceipt = item.meta && item.meta.source === 'receipt'; return ( <View> <View style={base.expenseItem}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> {item.title} </Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted }]}> {formatDate(item.date)} </Text> <View style={[ base.tag, { backgroundColor: theme.cardAlt, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.textMuted }, ]} > {item.category || 'Другое'} </Text> </View> {isReceipt && ( <View style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > ИИ-чек </Text> </View> )} </View> </View> <Text style={[ base.smallText, { color: item.type === 'income' ? theme.success : isImpulseHighlight ? theme.danger : theme.text, }, ]} > {item.type === 'income' ? '+' : '-'} {formatCurrency(item.amount, currency)} </Text> </View> <View style={[ base.divider, { backgroundColor: theme.border }, ]} /> </View> ); }} /> )} </View> </ScrollView> </View> ); }; const GoalsScreen = ({ userData, onUpdateUserData, theme }) => { const [title, setTitle] = useState(''); const [target, setTarget] = useState(''); const [months, setMonths] = useState(''); const [whatIfCafe, setWhatIfCafe] = useState('30'); const [whatIfSave, setWhatIfSave] = useState('10000'); const [goalImage, setGoalImage] = useState(null); const [editGoalId, setEditGoalId] = useState(null); const [editTitle, setEditTitle] = useState(''); const [editTarget, setEditTarget] = useState(''); const [editDeadlineMonths, setEditDeadlineMonths] = useState(''); const goals = userData.goals || []; const settings = userData.settings || {}; const currency = settings.currency || DEFAULT_CURRENCY; const handleAddGoal = () => { const t = parseFloat(String(target).replace(',', '.')); const m = parseInt(months, 10); if (!title || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) { Alert.alert('Ошибка', 'Укажите цель, сумму и срок в месяцах.'); return; } const deadline = new Date(); deadline.setMonth(deadline.getMonth() + m); const newGoal = { id: `goal_${Date.now()}`, title: title.trim(), targetAmount: t, savedAmount: 0, deadline: deadline.toISOString(), imageUri: goalImage || null, }; onUpdateUserData((prev) => ({ ...prev, goals: [...(prev.goals || []), newGoal], })); setTitle(''); setTarget(''); setMonths(''); setGoalImage(null); }; const handleDeposit = (goalId, amount) => { const n = amount || 0; onUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).map((g) => g.id === goalId ? { ...g, savedAmount: (Number(g.savedAmount) || 0) + n } : g, ), expenses: [ ...(prev.expenses || []), { id: `save_${Date.now()}`, title: 'Перевод в копилку', amount: n, category: 'Сбережения', type: 'expense', date: new Date().toISOString(), }, ], })); }; const handlePickGoalImage = async () => { const uri = await pickImageFromLibrary(); if (uri) setGoalImage(uri); }; const handleEditGoalPress = (goal) => { const now = new Date(); const deadline = goal.deadline ? new Date(goal.deadline) : null; const monthsLeft = deadline && !Number.isNaN(deadline.getTime()) ? Math.max( 1, Math.round( (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30), ), ) : ''; setEditGoalId(goal.id); setEditTitle(goal.title); setEditTarget(String(goal.targetAmount || '')); setEditDeadlineMonths(monthsLeft ? String(monthsLeft) : ''); }; const handleSaveEditedGoal = () => { if (!editGoalId) return; const t = parseFloat(String(editTarget).replace(',', '.')); const m = parseInt(editDeadlineMonths, 10); if (!editTitle || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) { Alert.alert('Ошибка', 'Укажите корректные данные для цели.'); return; } const newDeadline = new Date(); newDeadline.setMonth(newDeadline.getMonth() + m); onUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).map((g) => g.id === editGoalId ? { ...g, title: editTitle.trim(), targetAmount: t, deadline: newDeadline.toISOString(), } : g, ), })); setEditGoalId(null); setEditTitle(''); setEditTarget(''); setEditDeadlineMonths(''); }; const handleCancelEdit = () => { setEditGoalId(null); setEditTitle(''); setEditTarget(''); setEditDeadlineMonths(''); }; const handleDeleteGoal = (goalId) => { Alert.alert( 'Удалить цель', 'Вы уверены, что хотите удалить эту цель? Прогресс по ней будет потерян.', [ { text: 'Отмена', style: 'cancel' }, { text: 'Удалить', style: 'destructive', onPress: () => { onUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).filter((g) => g.id !== goalId), })); }, }, ], ); }; const now = new Date(); const totalSavedAll = sumBy(goals, (g) => Number(g.savedAmount) || 0); const totalTargetAll = sumBy(goals, (g) => Number(g.targetAmount) || 0); const cafeReduce = parseFloat(String(whatIfCafe).replace(',', '.')) || 0; const extraWeekly = parseFloat(String(whatIfSave).replace(',', '.')) || 0; const cafeCurrent = sumBy(userData.expenses || [], (e) => { if (e.type === 'income') return 0; const c = (e.category || '').toLowerCase(); if (c.includes('еда') || c.includes('кафе')) { const d = new Date(e.date || new Date()); const key = getMonthKey(d); if (key === getMonthKey(now)) { return Number(e.amount) || 0; } } return 0; }); const cafePotential = cafeCurrent * (cafeReduce / 100); const whatIfMonthlyExtra = cafePotential + extraWeekly * 4; return ( <ScrollView style={[ base.content, { backgroundColor: theme.background }, ]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" > {/* Блок создания цели */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Цели и копилки</Text> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>💎 Планы</Text> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего накоплено: {formatCurrency(totalSavedAll, currency)} из{' '} {formatCurrency(totalTargetAll || 0, currency)} </Text> <View style={{ marginTop: 12 }}> <TouchableOpacity onPress={handlePickGoalImage} style={[ base.row, { marginBottom: 8, }, ]} > {goalImage ? ( <Image source={{ uri: goalImage }} style={[base.goalImage, { width: 80, height: 80 }]} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}>📷</Text> </View> )} <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото цели (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте визуал — так легче держаться плана. </Text> </View> </TouchableOpacity> <TextInput placeholder="Название цели (например, «Ноутбук»)" placeholderTextColor={theme.textMuted} value={title} onChangeText={setTitle} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={target} onChangeText={setTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={months} onChangeText={setMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <TouchableOpacity onPress={handleAddGoal} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить цель </Text> </TouchableOpacity> </View> </View> {/* Список целей с редактированием и удалением */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Мои цели</Text> </View> {goals.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Создайте первую цель, чтобы следить за прогрессом. </Text> ) : ( goals.map((g) => { const targetAmount = Number(g.targetAmount) || 0; const savedAmount = Number(g.savedAmount) || 0; const pct = targetAmount ? Math.min(100, (savedAmount / targetAmount) * 100) : 0; const deadline = g.deadline ? new Date(g.deadline) : null; const daysLeft = deadline && !Number.isNaN(deadline.getTime()) ? Math.round( (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), ) : null; const left = Math.max(0, targetAmount - savedAmount); const perWeek = daysLeft && daysLeft > 0 ? (left / daysLeft) * 7 : left / 4 || 0; const isEditing = editGoalId === g.id; return ( <View key={g.id} style={{ marginBottom: 12 }}> <View style={base.rowBetween}> <Text style={[base.smallText, { color: theme.text, maxWidth: '70%' }]}> {g.title} </Text> <View style={base.row}> <TouchableOpacity onPress={() => handleEditGoalPress(g)}> <Text style={[base.tinyText, { color: theme.accent, marginRight: 12 }]}> Изм. </Text> </TouchableOpacity> <TouchableOpacity onPress={() => handleDeleteGoal(g.id)}> <Text style={[base.tinyText, { color: theme.danger }]}> Удалить </Text> </TouchableOpacity> </View> </View> {g.imageUri ? ( <Image source={{ uri: g.imageUri }} style={base.goalImage} /> ) : null} <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accent, width: `${pct}%`, }, ]} /> </View> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> {formatCurrency(savedAmount, currency)} из{' '} {formatCurrency(targetAmount, currency)} </Text> {daysLeft !== null && ( <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 4 }, ]} > Осталось ~{daysLeft} дн. </Text> )} </View> {left > 0 && ( <View style={base.rowBetween}> <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 2 }, ]} > Чтобы успеть: ~{formatCurrency(perWeek, currency)} в неделю </Text> <TouchableOpacity onPress={() => handleDeposit(g.id, 10000)} style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 2, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > +10 000 в копилку </Text> </TouchableOpacity> </View> )} {isEditing && ( <View style={{ marginTop: 8, padding: 10, borderRadius: 12, backgroundColor: theme.cardAlt, }} > <Text style={[base.tinyText, { color: theme.textMuted, marginBottom: 4 }]}> Редактирование цели </Text> <TextInput placeholder="Название цели" placeholderTextColor={theme.textMuted} value={editTitle} onChangeText={setEditTitle} style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={editTarget} onChangeText={setEditTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={editDeadlineMonths} onChangeText={setEditDeadlineMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={base.rowBetween}> <TouchableOpacity onPress={handleSaveEditedGoal} style={[ base.button, { flex: 1, backgroundColor: theme.accent, marginTop: 4, marginRight: 4, }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить </Text> </TouchableOpacity> <TouchableOpacity onPress={handleCancelEdit} style={[ base.button, { flex: 1, backgroundColor: theme.card, marginTop: 4, marginLeft: 4, borderWidth: 1, borderColor: theme.border, }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Отмена </Text> </TouchableOpacity> </View> </View> )} <View style={[ base.divider, { backgroundColor: theme.border, marginTop: 8 }, ]} /> </View> ); }) )} </View> {/* Блок «Что если?» */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>«Что если?»</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧪 Симулятор</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Смоделируйте изменения привычек и посмотрите влияние на накопления. </Text> <View style={{ marginTop: 8 }}> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> На сколько % сократить кафе/еду? </Text> <TextInput placeholder="30" placeholderTextColor={theme.textMuted} value={whatIfCafe} onChangeText={setWhatIfCafe} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Доп. отложить в неделю </Text> <TextInput placeholder="10 000" placeholderTextColor={theme.textMuted} value={whatIfSave} onChangeText={setWhatIfSave} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.smallText, { color: theme.text }]}> При таком сценарии вы сможете дополнительно накопить около{' '} {formatCurrency(whatIfMonthlyExtra, currency)} в месяц. </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> За год это будет примерно{' '} {formatCurrency(whatIfMonthlyExtra * 12, currency)} — почти целая отдельная цель 💡 </Text> </View> </View> </View> </ScrollView> ); }; const AiChatScreen = ({ userData, theme }) => { const [messages, setMessages] = useState([ { id: 'm_hello', from: 'ai', text: 'Привет! Я твой финансовый ИИ-ассистент. Задавай вопросы: «Как сократить расходы на 20%?», «Как распределить зарплату?», «Что будет, если начать откладывать больше?»', }, ]); const [input, setInput] = useState(''); const [voiceOn, setVoiceOn] = useState(false); const sendText = (text) => { const trimmed = text.trim(); if (!trimmed) return; const userMsg = { id: `m_${Date.now()}_u`, from: 'user', text: trimmed, }; const aiText = generateAiAnswer(trimmed, userData); const aiMsg = { id: `m_${Date.now()}_ai`, from: 'ai', text: aiText, }; setMessages((prev) => [...prev, userMsg, aiMsg]); setInput(''); if (voiceOn) { Speech.stop(); Speech.speak(aiText, { language: 'ru-RU' }); } }; const handleSend = () => { sendText(input); }; const quickPrompts = [ 'Как сократить расходы на 20%?', 'Как распределить зарплату 300 000?', 'Стоит ли покупать новый телефон?', 'Сделай обзор моих трат.', ]; return ( <KeyboardAvoidingView style={[ base.content, { backgroundColor: theme.background }, ]} behavior={Platform.OS === 'ios' ? 'padding' : undefined} > <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>ИИ-консультант</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}> Голос </Text> <Switch value={voiceOn} onValueChange={setVoiceOn} thumbColor={voiceOn ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Ответы основаны на ваших реальных данных: расходах, доходах, целях. </Text> <View style={{ marginTop: 8, flexDirection: 'row', flexWrap: 'wrap', }} > {quickPrompts.map((p) => ( <TouchableOpacity key={p} onPress={() => sendText(p)} style={[ base.badge, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.tinyText, { color: theme.text }]}>{p}</Text> </TouchableOpacity> ))} </View> </View> <View style={[ base.card, { flex: 1, backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 8 }} showsVerticalScrollIndicator={false} > {messages.map((m) => { const isUser = m.from === 'user'; return ( <View key={m.id} style={[ base.chatBubble, isUser ? base.chatUser : base.chatAi, { backgroundColor: isUser ? theme.accent : theme.cardAlt, }, ]} > <Text style={[ base.tinyText, { color: isUser ? '#FFFFFF' : theme.textMuted }, ]} > {isUser ? 'Вы' : 'ИИ'} </Text> <Text style={[ base.smallText, { color: isUser ? '#FFFFFF' : theme.text, marginTop: 2, }, ]} > {m.text} </Text> </View> ); })} </ScrollView> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, marginTop: 4, }, ]} > <TextInput placeholder="Задайте вопрос по финансам..." placeholderTextColor={theme.textMuted} value={input} onChangeText={setInput} style={[base.inputInline, { color: theme.text }]} multiline /> <TouchableOpacity onPress={handleSend}> <Text style={[base.smallText, { color: theme.accent }]}>Отпр.</Text> </TouchableOpacity> </View> </View> </KeyboardAvoidingView> ); }; const ProfileScreen = ({ currentUser, userData, onUpdateUserData, theme, onLogout, onToggleTheme, }) => { const profile = userData.profile || {}; const [firstName, setFirstName] = useState(profile.firstName || ''); const [lastName, setLastName] = useState(profile.lastName || ''); const [phone, setPhone] = useState(profile.phone || ''); const [birthDate, setBirthDate] = useState(profile.birthDate || ''); const [avatarUri, setAvatarUri] = useState(profile.avatarUri || ''); const [monthlyIncome, setMonthlyIncome] = useState( String((userData.settings && userData.settings.monthlyIncome) || ''), ); const [weeklyBudget, setWeeklyBudget] = useState( String((userData.settings && userData.settings.weeklyBudget) || ''), ); const [currency, setCurrency] = useState( (userData.settings && userData.settings.currency) || DEFAULT_CURRENCY, ); const [antiImpulseEnabled, setAntiImpulseEnabled] = useState( (userData.settings && userData.settings.antiImpulseEnabled) || false, ); const [antiImpulseThreshold, setAntiImpulseThreshold] = useState( String((userData.settings && userData.settings.antiImpulseThreshold) || ''), ); const [pinInput, setPinInput] = useState(''); const [noteDraft, setNoteDraft] = useState(''); const [noteUnlocked, setNoteUnlocked] = useState(false); useEffect(() => { const savedEncrypted = userData.secureNote || ''; if (savedEncrypted && currentUser.pin) { const dec = decryptNote(savedEncrypted, currentUser.pin); setNoteDraft(dec); } else if (savedEncrypted && !currentUser.pin) { setNoteDraft(savedEncrypted); } else { setNoteDraft(''); } }, [userData.secureNote, currentUser.pin]); useEffect(() => { const p = userData.profile || {}; setFirstName(p.firstName || ''); setLastName(p.lastName || ''); setPhone(p.phone || ''); setBirthDate(p.birthDate || ''); setAvatarUri(p.avatarUri || ''); }, [userData.profile]); const handleSaveSettings = () => { const m = parseFloat(String(monthlyIncome).replace(',', '.')) || 0; const w = parseFloat(String(weeklyBudget).replace(',', '.')) || 0; const anti = parseFloat(String(antiImpulseThreshold).replace(',', '.')) || 0; onUpdateUserData((prev) => ({ ...prev, income: m, settings: { ...(prev.settings || {}), monthlyIncome: m, weeklyBudget: w, currency: currency || DEFAULT_CURRENCY, antiImpulseEnabled, antiImpulseThreshold: anti, }, profile: { ...(prev.profile || {}), firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), birthDate: birthDate.trim(), avatarUri: avatarUri || '', }, })); Alert.alert('Сохранено', 'Профиль и бюджет обновлены.'); }; const handleUnlockNote = () => { if (!currentUser.pin) { setNoteUnlocked(true); return; } if (pinInput === currentUser.pin) { const dec = decryptNote(userData.secureNote || '', currentUser.pin); setNoteDraft(dec); setNoteUnlocked(true); } else { Alert.alert('Ошибка', 'Неверный PIN.'); } }; const handleSaveNote = () => { let encrypted = noteDraft; if (currentUser.pin) { encrypted = encryptNote(noteDraft, currentUser.pin); } onUpdateUserData((prev) => ({ ...prev, secureNote: encrypted, })); Alert.alert('Сохранено', 'Заметка зашифрована и сохранена.'); }; const totalSubs = (userData.subscriptions || []).length; const handlePickAvatar = async () => { const uri = await pickImageFromLibrary(); if (uri) setAvatarUri(uri); }; const displayName = firstName || lastName ? `${firstName} ${lastName}`.trim() : currentUser.email; return ( <ScrollView style={[ base.content, { backgroundColor: theme.background }, ]} showsVerticalScrollIndicator={false} > {/* Профиль и быстрый статус */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <View style={base.row}> <TouchableOpacity onPress={handlePickAvatar}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> {firstName ? firstName.charAt(0).toUpperCase() : currentUser.email.charAt(0).toUpperCase()} </Text> </View> )} </TouchableOpacity> <View style={{ marginLeft: 10 }}> <Text style={[base.cardTitle, { color: theme.text }]}> {displayName || 'Пользователь'} </Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> {currentUser.email} </Text> </View> </View> <TouchableOpacity onPress={onLogout}> <Text style={[base.smallText, { color: theme.danger }]}>Выйти</Text> </TouchableOpacity> </View> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Подписок учтено: {totalSubs} • PIN установлен:{' '} {currentUser.pin ? 'да' : 'нет'} </Text> <View style={{ marginTop: 12 }}> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} value={phone} onChangeText={setPhone} keyboardType="phone-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> {/* Бюджет и валюта */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Бюджет</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Установите доход, недельный бюджет и валюту — это нужно для индекса «финансовое здоровье» и рекомендаций. </Text> <TextInput placeholder="Доход в месяц" placeholderTextColor={theme.textMuted} value={monthlyIncome} onChangeText={setMonthlyIncome} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Недельный бюджет" placeholderTextColor={theme.textMuted} value={weeklyBudget} onChangeText={setWeeklyBudget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Валюта (например, ₸, ₽, $)" placeholderTextColor={theme.textMuted} value={currency} onChangeText={setCurrency} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TouchableOpacity onPress={handleSaveSettings} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить профиль и бюджет </Text> </TouchableOpacity> </View> {/* Тема */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Тема и вид</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 6 }]}> Тёмная тема </Text> <Switch value={theme.mode === 'dark'} onValueChange={onToggleTheme} thumbColor={theme.mode === 'dark' ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> В будущем сюда можно добавить иконки, виджеты и другие настройки UI. </Text> </View> {/* Анти-импульсная защита */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Контроль импульсных покупок</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧠 Эксперимент</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Включите анти-импульсный режим, чтобы приложение задавало вам «контрольный вопрос» перед крупной покупкой. </Text> <View style={[base.rowBetween, { marginTop: 8 }]}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> Анти-импульсный режим </Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Срабатывает при тратах выше порога. </Text> </View> <Switch value={antiImpulseEnabled} onValueChange={setAntiImpulseEnabled} thumbColor={antiImpulseEnabled ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> <TextInput placeholder="Порог крупной покупки (например, 50 000)" placeholderTextColor={theme.textMuted} value={antiImpulseThreshold} onChangeText={setAntiImpulseThreshold} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> </View> {/* Безопасная заметка */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Безопасная заметка</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔐 Локально</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Заметка шифруется простым алгоритмом (XOR + HEX) с вашим PIN и хранится только в AsyncStorage. Для реального проекта используйте надёжную криптографию (AES и т.п.) и биометрию. </Text> {!noteUnlocked && ( <> {currentUser.pin ? ( <> <TextInput placeholder="Введите PIN для разблокировки" placeholderTextColor={theme.textMuted} value={pinInput} onChangeText={setPinInput} keyboardType="number-pad" secureTextEntry style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleUnlockNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Разблокировать заметку </Text> </TouchableOpacity> </> ) : ( <> <Text style={[base.smallText, { color: theme.text, marginTop: 8 }]}> PIN не установлен при регистрации, поэтому заметка хранится без шифрования. </Text> <TouchableOpacity onPress={() => setNoteUnlocked(true)} style={[ base.button, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Показать заметку </Text> </TouchableOpacity> </> )} </> )} {noteUnlocked && ( <> <TextInput placeholder="Секретная финансовая заметка (пароли сюда лучше не писать)" placeholderTextColor={theme.textMuted} value={noteDraft} onChangeText={setNoteDraft} multiline style={[ base.input, { minHeight: 80, textAlignVertical: 'top', backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleSaveNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить зашифровано </Text> </TouchableOpacity> </> )} </View> </ScrollView> ); }; const MainApp = ({ currentUser, userData, setUserData, onLogout, themeMode, setThemeMode, }) => { const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME; const [activeTab, setActiveTab] = useState('home'); const handleUpdateUserData = (updater) => { setUserData((prev) => { const prevVal = prev || createDefaultUserData(themeMode); const next = typeof updater === 'function' ? updater(prevVal) : updater; return next; }); }; const handleToggleTheme = () => { setThemeMode((prev) => (prev === 'dark' ? 'light' : 'dark')); }; const profile = userData.profile || {}; const avatarUri = profile.avatarUri; const displayName = profile.firstName || profile.lastName ? `${profile.firstName || ''} ${profile.lastName || ''}`.trim() : currentUser.email; const initials = profile.firstName || profile.lastName ? `${(profile.firstName || profile.lastName || 'U').charAt(0).toUpperCase()}` : currentUser.email.charAt(0).toUpperCase(); return ( <SafeAreaView style={[ base.container, { backgroundColor: theme.background }, ]} > <StatusBar barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'} /> <View style={base.header}> <View style={base.row}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> {initials} </Text> </View> )} <View style={{ marginLeft: 10 }}> <Text style={[base.headerTitle, { color: theme.text }]}> Мой бюджет </Text> <Text style={[base.headerSubtitle, { color: theme.textMuted }]}> {displayName} </Text> </View> </View> <View style={base.chipRow}> <View style={[ base.chip, { backgroundColor: theme.accentSoft }, ]} > <Text style={[base.chipText, { color: theme.accent }]}>🤖 ИИ-чек</Text> </View> <TouchableOpacity onPress={handleToggleTheme} style={[ base.chip, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.chipText, { color: theme.text }]}> {theme.mode === 'dark' ? '◐' : '◑'} </Text> </TouchableOpacity> </View> </View> {activeTab === 'home' && <HomeScreen userData={userData} theme={theme} />} {activeTab === 'expenses' && ( <ExpensesScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'goals' && ( <GoalsScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'ai' && <AiChatScreen userData={userData} theme={theme} />} {activeTab === 'profile' && ( <ProfileScreen currentUser={currentUser} userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} onLogout={onLogout} onToggleTheme={handleToggleTheme} /> )} <View style={[ base.tabBar, { backgroundColor: theme.card, borderTopColor: theme.border }, ]} > {[ { id: 'home', label: 'Главная', icon: '🏡' }, { id: 'expenses', label: 'Траты', icon: '💳' }, { id: 'goals', label: 'Цели', icon: '📌' }, { id: 'ai', label: 'ИИ-чат', icon: '🧠' }, { id: 'profile', label: 'Профиль', icon: '👤' }, ].map((tab) => { const isActive = activeTab === tab.id; return ( <TouchableOpacity key={tab.id} onPress={() => setActiveTab(tab.id)} style={[ base.tabButton, { backgroundColor: isActive ? theme.accentSoft : 'transparent' }, ]} > <Text style={[ { fontSize: 16 }, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.icon} </Text> <Text style={[ base.tabLabel, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.label} </Text> </TouchableOpacity> ); })} </View> </SafeAreaView> ); }; // ---------- корневой компонент ---------- export default function App() { const [users, setUsers] = useState([]); const [session, setSession] = useState(null); const [userData, setUserData] = useState(null); const [themeMode, setThemeMode] = useState('dark'); const [loading, setLoading] = useState(true); const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME; useEffect(() => { const init = async () => { try { const usersJson = await AsyncStorage.getItem(STORAGE_KEYS.USERS); const sessionJson = await AsyncStorage.getItem(STORAGE_KEYS.SESSION); const loadedUsers = usersJson ? JSON.parse(usersJson) : []; setUsers(loadedUsers); if (sessionJson) { const s = JSON.parse(sessionJson); const u = loadedUsers.find((user) => user.id === s.userId); if (u) { setSession(s); const dataKey = `FINAPP_DATA_${u.id}`; const dataJson = await AsyncStorage.getItem(dataKey); if (dataJson) { const parsed = JSON.parse(dataJson); setUserData(parsed); if (parsed.settings && parsed.settings.themeMode) { setThemeMode(parsed.settings.themeMode); } } else { const defaultData = createDefaultUserData(themeMode); setUserData(defaultData); await AsyncStorage.setItem(dataKey, JSON.stringify(defaultData)); } } } } catch (e) { console.warn('Init error', e); } finally { setLoading(false); } }; init(); }, []); useEffect(() => { const persist = async () => { if (!session || !userData) return; try { const key = `FINAPP_DATA_${session.userId}`; const payload = { ...userData, settings: { ...(userData.settings || {}), themeMode, }, }; await AsyncStorage.setItem(key, JSON.stringify(payload)); } catch (e) { console.warn('Save userData error', e); } }; persist(); }, [userData, session, themeMode]); const handleLogin = async (user, profileFromRegistration = null) => { const s = { userId: user.id }; setSession(s); await AsyncStorage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(s)); const key = `FINAPP_DATA_${user.id}`; const dataJson = await AsyncStorage.getItem(key); if (dataJson) { const parsed = JSON.parse(dataJson); setUserData(parsed); if (parsed.settings && parsed.settings.themeMode) { setThemeMode(parsed.settings.themeMode); } } else { const defaultData = createDefaultUserData(themeMode, profileFromRegistration || {}); setUserData(defaultData); await AsyncStorage.setItem(key, JSON.stringify(defaultData)); } }; const handleRegister = async (newUser, profile) => { const updatedUsers = [...users, newUser]; setUsers(updatedUsers); await AsyncStorage.setItem(STORAGE_KEYS.USERS, JSON.stringify(updatedUsers)); await handleLogin(newUser, profile); }; const handleLogout = async () => { setSession(null); setUserData(null); await AsyncStorage.removeItem(STORAGE_KEYS.SESSION); }; if (loading) { return ( <SafeAreaView style={[ base.container, base.center, { backgroundColor: theme.background }, ]} > <StatusBar barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'} /> <Text style={[base.smallText, { color: theme.textMuted }]}> Загрузка данных... </Text> </SafeAreaView> ); } if (!session || !userData) { return ( <AuthScreen users={users} onLogin={handleLogin} onRegister={handleRegister} theme={theme} /> ); } const currentUser = users.find((u) => u.id === session.userId) || { id: session.userId, email: 'user', pin: '', }; return ( <MainApp currentUser={currentUser} userData={userData} setUserData={setUserData} onLogout={handleLogout} themeMode={themeMode} setThemeMode={setThemeMode} /> ); }
у меня snack expo dev нужно полностью переписать твой App.js под Snack отправь полный код исправленный import React, { useEffect, useState } from 'react';
import {
SafeAreaView,
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
FlatList,
StatusBar,
KeyboardAvoidingView,
Platform,
Switch,
Alert,
Image,
ActivityIndicator,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as ImagePicker from 'expo-image-picker';
import * as Speech from 'expo-speech';
import ExpoMlkitOcr from 'expo-mlkit-ocr'; // 👈 OCR на базе Google ML Kit
// ---------- константы ----------
const STORAGE_KEYS = {
USERS: 'FINAPP_USERS',
SESSION: 'FINAPP_SESSION',
};
const DARK_THEME = {
mode: 'dark',
background: '#020617',
card: '#020617',
cardAlt: '#0B1220',
accent: '#22C55E',
accentSoft: 'rgba(34,197,94,0.22)',
text: '#F9FAFB',
textMuted: '#9CA3AF',
border: '#1F2937',
danger: '#EF4444',
success: '#22C55E',
warning: '#EAB308',
};
const LIGHT_THEME = {
mode: 'light',
background: '#F3F4F6',
card: '#FFFFFF',
cardAlt: '#E5E7EB',
accent: '#0F766E',
accentSoft: 'rgba(15,118,110,0.16)',
text: '#111827',
textMuted: '#6B7280',
border: '#D1D5DB',
danger: '#DC2626',
success: '#16A34A',
warning: '#CA8A04',
};
const DEFAULT_CURRENCY = '₸';
const DEFAULT_CATEGORIES = [
'Еда',
'Транспорт',
'Жильё',
'Здоровье',
'Подписки',
'Игры/развлечения',
'Покупки',
'Сбережения',
'Другое',
];
// ---------- стили ----------
const base = StyleSheet.create({
flex1: { flex: 1 },
center: { justifyContent: 'center', alignItems: 'center' },
container: { flex: 1 },
authContainer: { flex: 1, paddingHorizontal: 24, paddingTop: 32, paddingBottom: 24 },
title: { fontSize: 28, fontWeight: '700', marginBottom: 4 },
subtitle: { fontSize: 14, marginBottom: 24 },
input: {
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 14,
marginBottom: 12,
},
button: {
borderRadius: 18,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
marginTop: 4,
},
buttonText: { fontSize: 15, fontWeight: '600' },
link: { fontSize: 14, marginTop: 12, textAlign: 'center' },
errorText: { fontSize: 13, marginBottom: 8 },
header: {
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerTitle: { fontSize: 22, fontWeight: '700' },
headerSubtitle: { fontSize: 12 },
chipRow: { flexDirection: 'row', alignItems: 'center' },
chip: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
marginLeft: 8,
},
chipText: { fontSize: 12 },
content: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 4,
},
card: {
borderRadius: 20,
padding: 16,
marginBottom: 14,
shadowColor: '#000',
shadowOpacity: 0.14,
shadowRadius: 12,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
cardHeaderRow: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 6,
},
cardTitle: { fontSize: 16, fontWeight: '600' },
cardSubtitle: { fontSize: 12 },
row: { flexDirection: 'row', alignItems: 'center' },
rowBetween: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
pill: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
marginLeft: 6,
},
pillText: { fontSize: 11, fontWeight: '500' },
tabBar: {
flexDirection: 'row',
paddingHorizontal: 6,
paddingVertical: 8,
borderTopWidth: StyleSheet.hairlineWidth,
},
tabButton: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
borderRadius: 999,
marginHorizontal: 4,
},
tabLabel: { fontSize: 11, marginTop: 2 },
badgeDot: {
width: 8,
height: 8,
borderRadius: 999,
marginRight: 4,
},
smallText: { fontSize: 12 },
tinyText: { fontSize: 11 },
sectionTitle: { fontSize: 14, fontWeight: '600', marginBottom: 8 },
inputRow: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 14,
paddingHorizontal: 10,
marginBottom: 8,
},
inputInline: { flex: 1, paddingVertical: 6, fontSize: 13 },
expenseItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 10,
},
divider: {
height: StyleSheet.hairlineWidth,
marginVertical: 6,
},
tag: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
marginRight: 6,
marginTop: 4,
},
tagText: { fontSize: 11 },
chartRow: { marginTop: 6 },
chartBarBg: {
height: 10,
borderRadius: 999,
overflow: 'hidden',
},
chartBarFill: {
height: 10,
borderRadius: 999,
},
chatBubble: {
padding: 10,
borderRadius: 14,
marginVertical: 4,
maxWidth: '85%',
},
chatUser: { alignSelf: 'flex-end' },
chatAi: { alignSelf: 'flex-start' },
badge: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 999,
marginRight: 6,
marginBottom: 6,
},
avatarCircle: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
avatarImage: {
width: 44,
height: 44,
borderRadius: 22,
},
goalImage: {
width: '100%',
height: 120,
borderRadius: 12,
marginTop: 6,
},
});
// ---------- helpers ----------
const formatCurrency = (amount, currency = DEFAULT_CURRENCY) => {
const n = Number.isFinite(amount) ? amount : 0;
const rounded = Math.round(n);
return ${currency} ${rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')};
};
const formatDate = (isoString) => {
if (!isoString) return '';
const d = new Date(isoString);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('ru-RU', {
day: '2-digit',
month: 'short',
});
};
const getMonthKey = (date) =>
${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')};
const guessCategory = (title) => {
if (!title) return 'Другое';
const t = title.toLowerCase();
if (/(super|market|магазин|магнит|кошелек|grocery|food|еда|dodo|kfc|burger)/.test(t)) return 'Еда';
if (/(аптека|pharm|apteka|drug)/.test(t)) return 'Здоровье';
if (/(taxi|такси|yandex go|bolt|uber|bus|metro)/.test(t)) return 'Транспорт';
if (/(аренда|rent|ipotek|ипотека|квартира)/.test(t)) return 'Жильё';
if (/(netflix|spotify|youtube|yandex plus|подписка|subscription)/.test(t)) return 'Подписки';
if (/(steam|psn|xbox|game|игр)/.test(t)) return 'Игры/развлечения';
if (/(cafe|кафе|coffee|кофе|restaurant|ресторан)/.test(t)) return 'Еда';
if (/(ozon|wildberries|wb|aliexpress)/.test(t)) return 'Покупки';
return 'Другое';
};
const sumBy = (arr, fn) => arr.reduce((acc, item, idx) => acc + (fn(item, idx) || 0), 0);
const buildMonthlyTotals = (expenses) => {
const totals = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
const key = getMonthKey(d);
totals[key] = (totals[key] || 0) + (Number(e.amount) || 0);
});
return totals;
};
const buildCategoryTotalsForMonth = (expenses, monthKey) => {
const m = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
if (getMonthKey(d) !== monthKey) return;
const cat = e.category || 'Другое';
m[cat] = (m[cat] || 0) + (Number(e.amount) || 0);
});
return m;
};
const forecastNextMonth = (expenses) => {
const totalsMap = buildMonthlyTotals(expenses);
const keys = Object.keys(totalsMap).sort();
if (keys.length === 0) return 0;
const ys = keys.map((k) => totalsMap[k]);
if (keys.length === 1) return ys[0];
const n = ys.length;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumX2 = 0;
for (let i = 0; i < n; i += 1) {
const x = i;
const y = ys[i];
sumX += x;
sumY += y;
sumXY += x * y;
sumX2 += x * x;
}
const denom = n * sumX2 - sumX * sumX;
const slope = denom === 0 ? 0 : (n * sumXY - sumX * sumY) / denom;
const intercept = (sumY - slope * sumX) / n;
const forecast = intercept + slope * n;
return forecast > 0 ? forecast : ys[ys.length - 1];
};
const detectAnomalies = (expenses) => {
if (!expenses.length) return [];
const amounts = expenses.map((e) => Number(e.amount) || 0);
const avg = sumBy(amounts, (a) => a) / amounts.length;
const threshold = Math.max(avg * 2, 0);
return expenses.filter((e) => (Number(e.amount) || 0) > threshold && threshold > 0);
};
const calculateDailyStreak = (expenses, monthlyIncome) => {
if (!expenses.length || !monthlyIncome) return 0;
const dailyBudget = monthlyIncome / 30;
if (dailyBudget <= 0) return 0;
const byDay = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
const key = d.toISOString().substring(0, 10);
byDay[key] = (byDay[key] || 0) + (Number(e.amount) || 0);
});
const today = new Date();
let streak = 0;
for (let i = 0; i < 365; i += 1) {
const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000);
const key = d.toISOString().substring(0, 10);
const spent = byDay[key] || 0;
if (spent <= dailyBudget) {
streak += 1;
} else {
break;
}
}
return streak;
};
const countNoSpendDaysThisMonth = (expenses) => {
if (!expenses.length) return 0;
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const spentDays = new Set();
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
if (d.getFullYear() !== year || d.getMonth() !== month) return;
spentDays.add(d.getDate());
});
return daysInMonth - spentDays.size;
};
const calculateFinancialHealth = (userData) => {
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const monthlyIncome = Number(settings.monthlyIncome) || Number(userData.income) || 0;
const now = new Date();
const currentMonthKey = getMonthKey(now);
const monthTotals = buildMonthlyTotals(expenses);
const totalThisMonth = monthTotals[currentMonthKey] || 0;
const savings = Math.max(0, monthlyIncome - totalThisMonth);
const savingsRate = monthlyIncome > 0 ? savings / monthlyIncome : 0;
const unnecessary = sumBy(expenses, (e) => {
if (e.type === 'income') return 0;
const d = new Date(e.date || new Date());
if (getMonthKey(d) !== currentMonthKey) return 0;
const c = (e.category || '').toLowerCase();
if (
c.includes('игр') ||
c.includes('развлеч') ||
c.includes('подпис') ||
c.includes('покуп') ||
c.includes('кафе') ||
c.includes('bar') ||
c.includes('club')
) {
return Number(e.amount) || 0;
}
return 0;
});
const unnecessaryShare = monthlyIncome > 0 ? unnecessary / monthlyIncome : 0;
let score = 50;
score += Math.min(20, savingsRate * 40);
score -= Math.min(15, unnecessaryShare * 30);
if (userData.goals && userData.goals.length) score += 5;
if (expenses.length > 20) score += 5;
score = Math.max(0, Math.min(100, Math.round(score)));
let label = 'Нужно больше данных';
if (score >= 80) {
label = 'Отличное состояние';
} else if (score >= 60) {
label = 'Хорошее состояние';
} else if (score >= 40) {
label = 'Среднее, есть над чем поработать';
} else {
label = 'Слабое, нужен план';
}
return {
score,
label,
monthlyIncome,
totalThisMonth,
savings,
unnecessary,
};
};
// простое шифрование заметок по PIN (XOR + hex, не для продакшена)
const encryptNote = (plain, pin) => {
if (!plain) return '';
if (!pin) return plain;
const digits = pin.split('').map((d) => parseInt(d, 10) || 0);
if (!digits.length) return plain;
let out = '';
for (let i = 0; i < plain.length; i += 1) {
const code = plain.charCodeAt(i) ^ digits[i % digits.length];
out += code.toString(16).padStart(2, '0');
}
return out;
};
const decryptNote = (cipher, pin) => {
if (!cipher) return '';
if (!pin) return cipher;
const digits = pin.split('').map((d) => parseInt(d, 10) || 0);
if (!digits.length) return cipher;
try {
let out = '';
for (let i = 0; i < cipher.length; i += 2) {
const hex = cipher.substr(i, 2);
const code = parseInt(hex, 16) ^ digits[(i / 2) % digits.length];
out += String.fromCharCode(code);
}
return out;
} catch (e) {
return '';
}
};
const buildSmartReminders = (userData) => {
const reminders = [];
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const now = new Date();
const nowKey = getMonthKey(now);
const last7Total = sumBy(expenses, (e) => {
if (e.type === 'income') return 0;
const d = new Date(e.date || new Date());
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24);
if (diff >= 0 && diff <= 7) {
return Number(e.amount) || 0;
}
return 0;
});
const weeklyBudget = Number(settings.weeklyBudget) || 0;
if (weeklyBudget > 0 && last7Total > weeklyBudget) {
reminders.push(
За последние 7 дней вы уже вышли за недельный бюджет на ${formatCurrency( last7Total - weeklyBudget, settings.currency || DEFAULT_CURRENCY, )}.,
);
} else if (weeklyBudget > 0 && last7Total > weeklyBudget * 0.8) {
reminders.push('Вы приблизились к недельному бюджету — самое время притормозить расходы.');
}
(userData.goals || []).forEach((g) => {
const target = Number(g.targetAmount) || 0;
const saved = Number(g.savedAmount) || 0;
if (!target) return;
const d = g.deadline ? new Date(g.deadline) : null;
const left = target - saved;
if (left <= 0 || !d) return;
const daysLeft = Math.round((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (daysLeft <= 0) return;
const perDay = left / daysLeft;
if (perDay > 0) {
reminders.push(
До цели «${g.title}» осталось ${formatCurrency( left, settings.currency || DEFAULT_CURRENCY, )}. Нужно откладывать примерно ${formatCurrency( perDay, settings.currency || DEFAULT_CURRENCY, )} в день.,
);
}
});
const monthTotals = buildMonthlyTotals(expenses);
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevKey = getMonthKey(prevMonth);
const totalNow = monthTotals[nowKey] || 0;
const totalPrev = monthTotals[prevKey] || 0;
if (totalPrev > 0 && totalNow > totalPrev * 1.15) {
const diff = totalNow - totalPrev;
reminders.push(
В этом месяце ваши траты уже на ${formatCurrency( diff, settings.currency || DEFAULT_CURRENCY, )} больше, чем в прошлом.,
);
}
if (!reminders.length) {
reminders.push('Вы молодец — критичных напоминаний сейчас нет 🎉');
}
return reminders;
};
const buildInsightsSummary = (userData) => {
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const now = new Date();
const currentMonthKey = getMonthKey(now);
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevKey = getMonthKey(prevMonth);
const monthTotals = buildMonthlyTotals(expenses);
const totalNow = monthTotals[currentMonthKey] || 0;
const totalPrev = monthTotals[prevKey] || 0;
const catNow = buildCategoryTotalsForMonth(expenses, currentMonthKey);
const catPrev = buildCategoryTotalsForMonth(expenses, prevKey);
const entriesNow = Object.entries(catNow);
const topCat = entriesNow.sort((a, b) => b[1] - a[1])[0];
const currency = settings.currency || DEFAULT_CURRENCY;
const anomalies = detectAnomalies(expenses).slice(0, 3);
const lines = [];
if (totalPrev > 0 && totalNow > 0) {
const delta = totalNow - totalPrev;
const pct = (delta / totalPrev) * 100;
if (Math.abs(pct) >= 5) {
if (pct > 0) {
lines.push(
Общие расходы выросли на ${pct.toFixed(1)}% по сравнению с прошлым месяцем (${formatCurrency( totalNow, currency, )} против ${formatCurrency(totalPrev, currency)}).,
);
} else {
lines.push(
Вы тратите на ${Math.abs(pct).toFixed( 1, )}% меньше, чем в прошлом месяце — отличный прогресс 👏,
);
}
}
}
if (topCat) {
lines.push(
Больше всего вы тратите на категорию «${topCat[0]}» — ${formatCurrency( topCat[1], currency, )} в этом месяце.,
);
}
Object.keys(catNow).forEach((c) => {
const nowVal = catNow[c];
const prevVal = catPrev[c] || 0;
if (prevVal <= 0) return;
const pct = (nowVal - prevVal) / prevVal;
if (pct >= 0.25 && nowVal > 0) {
lines.push(
Категория «${c}» выросла на ${Math.round( pct * 100, )}% относительно прошлого месяца — стоит проверить, можно ли там сэкономить.,
);
}
});
if (anomalies.length) {
const txt = anomalies
.map((a) => "${a.title}" на ${formatCurrency(a.amount, currency)})
.join(', ');
lines.push(Обнаружены разовые крупные траты: ${txt}. Убедитесь, что они действительно нужны.);
}
if (!lines.length) {
lines.push('Пока недостаточно данных для глубокого анализа. Начните добавлять траты 🙂');
}
return lines;
};
const generateAiAnswer = (promptRaw, userData) => {
const prompt = promptRaw.toLowerCase();
const health = calculateFinancialHealth(userData);
const insights = buildInsightsSummary(userData);
const currency = (userData.settings && userData.settings.currency) || DEFAULT_CURRENCY;
const expenses = userData.expenses || [];
const monthlyTotals = buildMonthlyTotals(expenses);
const nowKey = getMonthKey(new Date());
const totalThisMonth = monthlyTotals[nowKey] || 0;
if (!expenses.length && !prompt.includes('регистрац')) {
return 'У меня пока нет ваших данных. Добавьте несколько трат и доходов — тогда я смогу давать персональные советы.';
}
if (prompt.includes('сократить') || prompt.includes('эконом') || prompt.includes('сэкономить')) {
const catNow = buildCategoryTotalsForMonth(expenses, nowKey);
const entries = Object.entries(catNow).sort((a, b) => b[1] - a[1]);
const top3 = entries.slice(0, 3);
const tips = top3
.map(
([c, v]) =>
• «${c}» — ${formatCurrency( v, currency, )} в месяц. Попробуйте снизить хотя бы на 10–20%.,
)
.join('\n');
return (
Сейчас ваши траты за месяц: ${formatCurrency( totalThisMonth, currency, )}. Чтобы сократить их, лучше всего начать с самых крупных категорий:\n\n${tips}\n\n +
'Дополнительно:\n' +
'• установите недельный бюджет и фиксируйте все траты;\n' +
'• избегайте спонтанных покупок — делайте «паузу 24 часа»;\n' +
'• для подписок — оставьте только то, чем пользуетесь каждую неделю.'
);
}
if (prompt.includes('как распределить') || prompt.includes('зарплат')) {
const match = promptRaw.match(/(\d[\d\s]{2,})/);
const income = match ? parseInt(match[1].replace(/\s/g, ''), 10) : health.monthlyIncome || 0;
if (!income) {
return 'Напишите, пожалуйста, размер зарплаты (например: «Как распределить зарплату 300 000?»), или задайте месячный доход в настройках. Тогда я предложу детальный план распределения.';
}
const needs = income * 0.5;
const wants = income * 0.3;
const saving = income * 0.2;
return (
Классическая модель 50/30/20 для зарплаты ${formatCurrency(income, currency)}:\n\n +
• 50% (${formatCurrency(needs, currency)}) — обязательные расходы (жильё, еда, транспорт, кредиты);\n +
• 30% (${formatCurrency(wants, currency)}) — «хотелки» и развлечения;\n +
• 20% (${formatCurrency(saving, currency)}) — сбережения и цели.\n\n +
'Вы можете адаптировать: если есть кредиты — уменьшите «хотелки» до 10–15% и направьте разницу на досрочное погашение и накопления.'
);
}
if (prompt.includes('стоит ли покупать') || prompt.includes('покупать')) {
return (
'Задайте себе три вопроса перед покупкой:\n' +
'1) Нужна ли вещь прямо сейчас или это импульс?\n' +
'2) Сколько часов вашей работы она стоит?\n' +
'3) Через месяц вы будете так же рады этой покупке?\n\n' +
'Если сумма покупки >10% от месячного дохода — хорошо бы выдержать «паузу 24 часа». ' +
'Также посмотрите, не мешает ли эта покупка вашим финансовым целям (накоплениям и т.п.).'
);
}
if (prompt.includes('что если') || prompt.includes('если буду')) {
return (
'Функция «Что если?» уже доступна на вкладке «Цели» — там можно смоделировать, что будет, если сократить траты на кафе или начать откладывать больше. ' +
'Если хотите, сформулируйте сценарий конкретно (например: «Что будет, если каждый месяц откладывать на 20 000 больше?»), и я прикину эффект в чате.'
);
}
const baseInsights = insights.slice(0, 2).join('\n\n');
return (
Краткий обзор вашего финансового состояния:\n +
• Индекс финансового здоровья: ${health.score}/100 (${health.label}).\n +
• Траты в этом месяце: ${formatCurrency(totalThisMonth, currency)}.\n +
• Потенциальные сбережения: ${formatCurrency(health.savings, currency)}.\n\n +
${baseInsights}\n\n +
'Задавайте мне конкретные вопросы: например, «Как сократить расходы на 20%?» или ' +
'«Помоги перераспределить бюджет с учётом цели на 800 000».'
);
};
// helper для дефолтных данных пользователя
const createDefaultUserData = (themeMode, profile = {}) => ({
income: 0,
expenses: [],
goals: [],
subscriptions: [],
settings: {
monthlyIncome: 0,
weeklyBudget: 0,
currency: DEFAULT_CURRENCY,
antiImpulseEnabled: false,
antiImpulseThreshold: 0,
themeMode: themeMode || 'dark',
},
profile,
secureNote: '',
});
// helper для выбора картинки из галереи
const pickImageFromLibrary = async () => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Нет доступа', 'Разрешите доступ к фото в настройках устройства.');
return null;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.8,
});
if (result.canceled) return null;
const asset = result.assets && result.assets[0];
if (!asset) return null;
return asset.uri;
};
// ---------- OCR + ИИ-проверка чеков ----------
// Реальный OCR на базе Google ML Kit через expo-mlkit-ocr
const runOcrOnReceiptImage = async (uri) => {
try {
console.log('runOcrOnReceiptImage: ML Kit OCR, uri:', uri);
text// Библиотека expo-mlkit-ocr сейчас официально поддерживает только Android if (Platform.OS !== 'android') { console.warn('expo-mlkit-ocr официально поддерживает только Android. На iOS может не работать.'); } // recognizeText(uri) -> { text: string, blocks: [...] } const result = await ExpoMlkitOcr.recognizeText(uri); if (result && typeof result.text === 'string' && result.text.trim().length > 0) { return result.text; } if (result && Array.isArray(result.blocks)) { return result.blocks .map((b) => b.text) .filter(Boolean) .join('\n'); } return '';
} catch (e) {
console.warn('runOcrOnReceiptImage OCR error:', e);
return '';
}
};
// «Умный» парсер текста чека: пытается понять именно итоговую сумму
const parseReceiptOcrText = (text) => {
if (!text) return null;
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) return null;
// Денежный паттерн: 1 234,56 / 1234.56 / 12 345 / 12345 и т.п.
const MONEY_REGEX =
/\d{1,3}(?:[ \u00A0]\d{3})*(?:[.,]\d{1,2})?|\d+[.,]\d{1,2}/g;
const parseMoney = (raw) => {
if (!raw) return NaN;
let cleaned = String(raw)
.replace(/\u00A0/g, ' ')
.replace(/[^\d.,\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
textif (!cleaned) return NaN; cleaned = cleaned.replace(/\s/g, ''); if (cleaned.includes(',') && cleaned.includes('.')) { const lastComma = cleaned.lastIndexOf(','); const lastDot = cleaned.lastIndexOf('.'); if (lastComma > lastDot) { cleaned = cleaned.replace(/\./g, '').replace(',', '.'); } else { cleaned = cleaned.replace(/,/g, ''); } } else if (cleaned.includes(',')) { cleaned = cleaned.replace(',', '.'); } const value = parseFloat(cleaned); return Number.isFinite(value) ? value : NaN;
};
const isTotalLine = (line) => {
const lower = line.toLowerCase().replace(/\s+/g, ' ');
return (
/итог(о)?/.test(lower) ||
/к оплате/.test(lower) ||
/сумма к оплате/.test(lower) ||
/итог по чеку/.test(lower) ||
/total/.test(lower)
);
};
let amount = null;
// 1) Ищем строку с ИТОГ / К ОПЛАТЕ и берём сумму оттуда
for (const line of lines) {
if (!isTotalLine(line)) continue;
textconst matches = line.match(MONEY_REGEX); if (matches && matches.length) { const raw = matches[matches.length - 1]; const v = parseMoney(raw); if (Number.isFinite(v) && v > 0) { amount = v; break; } }
}
// 2) Если не нашли — смотрим последние 3–5 строк
if (amount == null) {
const tail = lines.slice(-5);
const candidates = [];
texttail.forEach((line) => { const matches = line.match(MONEY_REGEX); if (!matches) return; matches.forEach((m) => { const v = parseMoney(m); if (Number.isFinite(v) && v > 0 && v < 1e7) { candidates.push(v); } }); }); if (candidates.length) { amount = Math.max(...candidates); }
}
// 3) Совсем fallback — ищем по всему тексту
if (amount == null) {
const candidates = [];
lines.forEach((line) => {
const matches = line.match(MONEY_REGEX);
if (!matches) return;
matches.forEach((m) => {
const v = parseMoney(m);
if (Number.isFinite(v) && v > 0 && v < 1e7) {
candidates.push(v);
}
});
});
textif (candidates.length) { amount = Math.max(...candidates); }
}
// 4) Название магазина / покупки
let titleLine = '';
for (const line of lines) {
const letters = line.replace(/[^A-Za-zА-Яа-яЁё]/g, '');
if (!letters.length) continue;
textif (isTotalLine(line)) continue; if (/кассир|смена|чек|фискальн|ккм|рнк|зн/i.test(line)) continue; titleLine = line; break;
}
if (!titleLine) {
titleLine = 'Покупка по чеку';
}
const category = guessCategory(titleLine);
return {
title: titleLine,
amount: Number.isFinite(amount) && amount > 0 ? amount : 0,
category,
};
};
// ---------- небольшие компоненты ----------
const CategoryBarChart = ({ data, theme }) => {
if (!data || !data.length) return null;
const max = Math.max(...data.map((d) => d.value || 0));
if (!max) return null;
return (
<View style={base.chartRow}>
{data.map((item) => {
const pct = (item.value / max) * 100;
return (
<View key={item.label} style={{ marginBottom: 6 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>{item.label}</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{formatCurrency(item.value)}
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 3 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: theme.accent,
width: ${pct}%,
},
]}
/>
</View>
</View>
);
})}
</View>
);
};
const SimpleMonthlyChart = ({ expenses, theme }) => {
const totalsMap = buildMonthlyTotals(expenses || []);
const keys = Object.keys(totalsMap).sort();
if (!keys.length) return null;
const items = keys.slice(-6).map((k) => ({ key: k, value: totalsMap[k] }));
const max = Math.max(...items.map((i) => i.value));
if (!max) return null;
return (
<View style={{ marginTop: 4 }}>
{items.map((item) => {
const [year, month] = item.key.split('-');
const label = ${month}.${year.slice(-2)};
const pct = (item.value / max) * 100;
return (
<View key={item.key} style={{ marginBottom: 4 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>{label}</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{formatCurrency(item.value)}
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 3 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: theme.accentSoft,
width: ${pct}%,
},
]}
/>
</View>
</View>
);
})}
</View>
);
};
const SpendingGauge = ({ monthlyIncome, spent, theme, currency }) => {
if (!monthlyIncome) return null;
const ratio = monthlyIncome > 0 ? spent / monthlyIncome : 0;
const pct = Math.max(0, Math.min(1, ratio));
const labelPct = (pct * 100).toFixed(0);
return (
<View style={{ marginTop: 10 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>Расходы от дохода</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{labelPct}% ({formatCurrency(spent, currency)})
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 4, height: 12 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: ratio > 1 ? theme.danger : theme.accent,
width: ${pct * 100}%,
},
]}
/>
</View>
{ratio > 1 && (
<Text style={[base.tinyText, { color: theme.danger, marginTop: 4 }]}>
Вы вышли за рамки месячного дохода — стоит пересмотреть план.
</Text>
)}
</View>
);
};
// ---------- экраны ----------
const AuthScreen = ({ users, onLogin, onRegister, theme }) => {
const [mode, setMode] = useState('login'); // 'login' | 'register'
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [pin, setPin] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [phone, setPhone] = useState('');
const [birthDate, setBirthDate] = useState('');
const [avatarUri, setAvatarUri] = useState('');
const [error, setError] = useState('');
const toggleMode = () => {
setError('');
setMode((m) => (m === 'login' ? 'register' : 'login'));
};
const handleLoginPress = () => {
setError('');
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail || !password) {
setError('Заполните email и пароль.');
return;
}
const user = users.find((u) => u.email === normalizedEmail);
if (!user || user.password !== password) {
setError('Неверный email или пароль.');
return;
}
onLogin(user);
};
const handleRegisterPress = () => {
setError('');
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail || !password) {
setError('Заполните email и пароль.');
return;
}
if (!normalizedEmail.includes('@')) {
setError('Некорректный email.');
return;
}
if (password.length < 4) {
setError('Пароль должен быть не короче 4 символов.');
return;
}
if (!firstName.trim()) {
setError('Укажите имя.');
return;
}
if (!phone.trim()) {
setError('Укажите номер телефона.');
return;
}
if (users.some((u) => u.email === normalizedEmail)) {
setError('Пользователь с таким email уже существует.');
return;
}
const newUser = {
id: u_${Date.now()},
email: normalizedEmail,
password,
pin: pin || '',
createdAt: new Date().toISOString(),
};
const profile = {
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim(),
birthDate: birthDate.trim(),
avatarUri: avatarUri || '',
};
onRegister(newUser, profile);
};
const handlePickAvatar = async () => {
const uri = await pickImageFromLibrary();
if (uri) {
setAvatarUri(uri);
}
};
return (
<SafeAreaView
style={[
base.container,
{ backgroundColor: theme.background },
]}
>
<StatusBar
barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'}
/>
<KeyboardAvoidingView
style={base.flex1}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={[base.authContainer]}>
<View style={{ marginBottom: 32 }}>
<Text style={[base.title, { color: theme.text }]}>
Финансовый ИИ-помощник
</Text>
<Text style={[base.subtitle, { color: theme.textMuted }]}>
Управляй доходами, расходами, целями и сканируй чеки с ИИ.
</Text>
</View>
text<View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[ base.cardTitle, { color: theme.text }, ]} > {mode === 'login' ? 'Вход' : 'Регистрация'} </Text> <View style={[ base.pill, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.pillText, { color: theme.accent }, ]} > ⚡ ИИ + чек-сканер </Text> </View> </View> {error ? ( <Text style={[base.errorText, { color: theme.danger }]}>{error}</Text> ) : null} {mode === 'register' && ( <> <View style={base.row}> <TouchableOpacity onPress={handlePickAvatar}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> + </Text> </View> )} </TouchableOpacity> <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото профиля (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте аватар, чтобы приложение стало более личным. </Text> </View> </View> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} keyboardType="phone-pad" value={phone} onChangeText={setPhone} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </> )} <TextInput placeholder="Email" placeholderTextColor={theme.textMuted} autoCapitalize="none" keyboardType="email-address" value={email} onChangeText={setEmail} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Пароль" placeholderTextColor={theme.textMuted} secureTextEntry value={password} onChangeText={setPassword} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> {mode === 'register' && ( <TextInput placeholder="PIN (необязательно, для шифрования заметок)" placeholderTextColor={theme.textMuted} value={pin} onChangeText={setPin} keyboardType="number-pad" maxLength={6} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> )} <TouchableOpacity onPress={mode === 'login' ? handleLoginPress : handleRegisterPress} style={[ base.button, { backgroundColor: theme.accent, marginTop: 8 }, ]} > <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {mode === 'login' ? 'Войти' : 'Зарегистрироваться'} </Text> </TouchableOpacity> <TouchableOpacity onPress={toggleMode}> <Text style={[ base.link, { color: theme.textMuted }, ]} > {mode === 'login' ? 'Нет аккаунта? Создать' : 'Уже есть аккаунт? Войти'} </Text> </TouchableOpacity> </View> <View style={{ marginTop: 24 }}> <Text style={[ base.smallText, { color: theme.textMuted }, ]} > Данные хранятся только локально на устройстве (AsyncStorage). Для реального приложения стоит добавить серверный бэкап и более серьёзную криптографию. </Text> </View> </View> </KeyboardAvoidingView> </SafeAreaView>
);
};
const HomeScreen = ({ userData, theme }) => {
const health = calculateFinancialHealth(userData);
const insights = buildInsightsSummary(userData);
const reminders = buildSmartReminders(userData);
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const now = new Date();
const currentMonthKey = getMonthKey(now);
const catNowMap = buildCategoryTotalsForMonth(expenses, currentMonthKey);
const categoryData = Object.entries(catNowMap)
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value);
const forecast = forecastNextMonth(expenses);
const streak = calculateDailyStreak(
expenses,
Number(settings.monthlyIncome) || Number(userData.income) || 0,
);
const noSpendDays = countNoSpendDaysThisMonth(expenses);
const profile = userData.profile || {};
const firstName = profile.firstName || '';
const badges = [];
if (health.score >= 80) badges.push('Экономист месяца');
if (streak >= 7) badges.push('7 дней в плюсе');
if (noSpendDays >= 5) badges.push('Дни без трат');
if (expenses.length >= 50) badges.push('Финансовый самурай');
return (
<ScrollView
style={[
base.content,
{ backgroundColor: theme.background },
]}
showsVerticalScrollIndicator={false}
>
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.rowBetween}>
<View style={{ flex: 1 }}>
<Text style={[base.cardSubtitle, { color: theme.textMuted }]}>
Индекс финансового здоровья
</Text>
<Text
style={[
{ fontSize: 32, fontWeight: '700', marginTop: 4 },
{ color: theme.text },
]}
>
{health.score}
<Text style={{ fontSize: 16 }}>/100</Text>
</Text>
{firstName ? (
<Text
style={[
base.tinyText,
{ color: theme.textMuted, marginTop: 4 },
]}
>
{firstName}, это ваш персональный индекс.
</Text>
) : null}
</View>
<View
style={[
{
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
},
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.tinyText, { color: theme.textMuted }]}>Статус</Text>
<Text
style={[
base.smallText,
{ color: theme.text, fontWeight: '600', marginTop: 2 },
]}
>
{health.label}
</Text>
</View>
</View>
text<View style={{ marginTop: 10 }}> <View style={base.rowBetween}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Доход / мес.</Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {health.monthlyIncome ? formatCurrency(health.monthlyIncome, currency) : 'Не задан'} </Text> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Траты в этом месяце </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(health.totalThisMonth, currency)} </Text> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Сбережения</Text> <Text style={[ base.smallText, { color: health.savings >= 0 ? theme.success : theme.danger, marginTop: 2, }, ]} > {formatCurrency(health.savings, currency)} </Text> </View> <SpendingGauge monthlyIncome={health.monthlyIncome} spent={health.totalThisMonth} theme={theme} currency={currency} /> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Привычки</Text> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Серия дней в рамках бюджета: {streak} </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Дней без трат в этом месяце: {noSpendDays} </Text> </View> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Бейджи</Text> <View style={[base.row, { flexWrap: 'wrap', marginTop: 4 }]}> {badges.length ? ( badges.map((b) => ( <View key={b} style={[ base.badge, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.tinyText, { color: theme.accent }, ]} > ✨ {b} </Text> </View> )) ) : ( <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Собирайте награды, держась в рамках бюджета. </Text> )} </View> </View> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>AI-инсайты</Text> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>🧩 Анализ</Text> </View> </View> {insights.slice(0, 3).map((line, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{line}</Text> </View> ))} </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Категории</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Текущий месяц </Text> </View> <CategoryBarChart data={categoryData} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Динамика</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Прогноз: {formatCurrency(forecast, currency)} </Text> </View> <SimpleMonthlyChart expenses={expenses} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Умные напоминания</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔔</Text> </View> {reminders.map((r, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{r}</Text> </View> ))} </View> </ScrollView>
);
};
const ExpensesScreen = ({ userData, onUpdateUserData, theme }) => {
const [title, setTitle] = useState('');
const [amount, setAmount] = useState('');
const [category, setCategory] = useState('');
const [isIncome, setIsIncome] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [lastReceiptPreview, setLastReceiptPreview] = useState(null); // { uri, text }
const expenses = [...(userData.expenses || [])].sort(
(a, b) => new Date(b.date || 0).getTime() - new Date(a.date || 0).getTime(),
);
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const antiImpulseEnabled = settings.antiImpulseEnabled;
const antiImpulseThreshold = Number(settings.antiImpulseThreshold) || 0;
const addEntry = (n, guessed, customTitle, extraMeta) => {
const entry = {
id: exp_${Date.now()},
title: (customTitle || title).trim(),
amount: n,
category: guessed,
type: isIncome ? 'income' : 'expense',
date: new Date().toISOString(),
...(extraMeta ? { meta: extraMeta } : {}),
};
onUpdateUserData((prev) => ({
...prev,
expenses: [...(prev.expenses || []), entry],
}));
setTitle('');
setAmount('');
setCategory('');
setIsIncome(false);
};
const handleAdd = () => {
const n = parseFloat(String(amount).replace(',', '.'));
if (!title || !Number.isFinite(n) || n <= 0) {
Alert.alert('Ошибка', 'Введите название и сумму.');
return;
}
const guessed = category || guessCategory(title);
textif (!isIncome && antiImpulseEnabled && antiImpulseThreshold > 0 && n >= antiImpulseThreshold) { Alert.alert( 'Анти-импульсная защита', `Покупка на ${formatCurrency( n, currency, )} превышает ваш лимит в ${formatCurrency(antiImpulseThreshold, currency)}.\n\n` + 'Вы уверены, что хотите продолжить?', [ { text: 'Отменить', style: 'cancel' }, { text: 'Купить', style: 'destructive', onPress: () => addEntry(n, guessed), }, ], ); return; } addEntry(n, guessed);
};
const handleScanReceipt = async () => {
try {
setIsScanning(true);
const perm = await ImagePicker.requestCameraPermissionsAsync();
if (!perm.granted) {
Alert.alert('Нет доступа', 'Разрешите доступ к камере в настройках устройства.');
return;
}
textconst result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8, }); if (result.canceled) return; const asset = result.assets && result.assets[0]; if (!asset) return; const uri = asset.uri; const ocrText = await runOcrOnReceiptImage(uri); setLastReceiptPreview({ uri, text: ocrText }); const parsed = parseReceiptOcrText(ocrText); if (!parsed || !parsed.amount || parsed.amount <= 0) { Alert.alert( 'Не удалось распознать чек', 'Попробуйте сделать фото поярче и ровнее, чтобы мы могли прочитать сумму.', ); return; } const guessedCategory = parsed.category || guessCategory(parsed.title); const n = parsed.amount; // Авто-добавление в расходы onUpdateUserData((prev) => ({ ...prev, expenses: [ ...(prev.expenses || []), { id: `exp_receipt_${Date.now()}`, title: parsed.title || 'Покупка по чеку', amount: n, category: guessedCategory, type: 'expense', date: new Date().toISOString(), meta: { source: 'receipt', ocrText, imageUri: uri, }, }, ], })); Alert.alert( 'Чек добавлен', `Мы распознали сумму ${formatCurrency( n, currency, )} и категорию «${guessedCategory}».\nЗапись добавлена в историю расходов.`, ); } catch (e) { console.warn('Receipt scan error', e); Alert.alert('Ошибка', 'Не удалось обработать чек. Попробуйте ещё раз.'); } finally { setIsScanning(false); }
};
const totalExpenses = sumBy(expenses, (e) =>
e.type === 'income' ? 0 : Number(e.amount) || 0,
);
const totalIncome = sumBy(expenses, (e) =>
e.type === 'income' ? Number(e.amount) || 0 : 0,
);
return (
<View
style={[
base.content,
{ backgroundColor: theme.background },
]}
>
<ScrollView
style={{ flex: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* ИИ-проверка чеков */}
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<View style={{ flex: 1, paddingRight: 6 }}>
<Text style={[base.cardTitle, { color: theme.text }]}>
ИИ-проверка чеков
</Text>
<Text style={[base.tinyText, { color: theme.textMuted, marginTop: 2 }]}>
Сделайте фото чека — мы распознаем сумму и автоматически добавим расход.
</Text>
</View>
<View style={[base.pill, { backgroundColor: theme.accentSoft }]}>
<Text style={[base.pillText, { color: theme.accent }]}>🤖 Чек-сканер</Text>
</View>
</View>
text<TouchableOpacity onPress={handleScanReceipt} disabled={isScanning} style={[ base.button, { backgroundColor: isScanning ? theme.cardAlt : theme.accent, marginTop: 8, flexDirection: 'row', }, ]} > {isScanning && ( <ActivityIndicator size="small" style={{ marginRight: 8 }} color="#FFFFFF" /> )} <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {isScanning ? 'Сканируем чек...' : 'Сканировать чек с ИИ'} </Text> </TouchableOpacity> {lastReceiptPreview && ( <View style={[base.row, { marginTop: 10 }]}> <Image source={{ uri: lastReceiptPreview.uri }} style={{ width: 56, height: 56, borderRadius: 12, marginRight: 8 }} /> <View style={{ flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Последний чек (OCR) </Text> <Text numberOfLines={3} style={[base.smallText, { color: theme.text, marginTop: 2 }]} > {lastReceiptPreview.text || 'Текст не распознан'} </Text> </View> </View> )} </View> {/* Ручное добавление записи */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Новая запись</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}> Доход </Text> <Switch value={isIncome} onValueChange={setIsIncome} thumbColor={isIncome ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Магазин / описание" placeholderTextColor={theme.textMuted} value={title} onChangeText={(text) => { setTitle(text); if (!category) { setCategory(guessCategory(text)); } }} style={[base.inputInline, { color: theme.text }]} /> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} keyboardType="numeric" value={amount} onChangeText={setAmount} style={[base.inputInline, { color: theme.text }]} /> <Text style={[base.smallText, { color: theme.textMuted }]}>{currency}</Text> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Категория</Text> <ScrollView horizontal showsHorizontalScrollIndicator={false}> {DEFAULT_CATEGORIES.map((c) => ( <TouchableOpacity key={c} onPress={() => setCategory(c)} style={[ base.tag, { backgroundColor: category === c ? theme.accentSoft : theme.cardAlt, borderWidth: 1, borderColor: category === c ? theme.accent : theme.border, }, ]} > <Text style={[ base.tagText, { color: category === c ? theme.accent : theme.textMuted, }, ]} > {c} </Text> </TouchableOpacity> ))} </ScrollView> </View> <TouchableOpacity onPress={handleAdd} style={[ base.button, { backgroundColor: theme.accent, marginTop: 12 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить </Text> </TouchableOpacity> </View> {/* Итоги */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.rowBetween}> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего расходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalExpenses, currency)} </Text> </View> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего доходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalIncome, currency)} </Text> </View> </View> </View> {/* История */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>История</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> {expenses.length} записей </Text> </View> {expenses.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Добавьте первую трату или доход. </Text> ) : ( <FlatList data={expenses} keyExtractor={(item) => item.id} scrollEnabled={false} renderItem={({ item }) => { const isImpulseHighlight = item.type !== 'income' && antiImpulseEnabled && antiImpulseThreshold > 0 && Number(item.amount) >= antiImpulseThreshold; const isReceipt = item.meta && item.meta.source === 'receipt'; return ( <View> <View style={base.expenseItem}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> {item.title} </Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted }]}> {formatDate(item.date)} </Text> <View style={[ base.tag, { backgroundColor: theme.cardAlt, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.textMuted }, ]} > {item.category || 'Другое'} </Text> </View> {isReceipt && ( <View style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > ИИ-чек </Text> </View> )} </View> </View> <Text style={[ base.smallText, { color: item.type === 'income' ? theme.success : isImpulseHighlight ? theme.danger : theme.text, }, ]} > {item.type === 'income' ? '+' : '-'} {formatCurrency(item.amount, currency)} </Text> </View> <View style={[ base.divider, { backgroundColor: theme.border }, ]} /> </View> ); }} /> )} </View> </ScrollView> </View>
);
};
const GoalsScreen = ({ userData, onUpdateUserData, theme }) => {
const [title, setTitle] = useState('');
const [target, setTarget] = useState('');
const [months, setMonths] = useState('');
const [whatIfCafe, setWhatIfCafe] = useState('30');
const [whatIfSave, setWhatIfSave] = useState('10000');
const [goalImage, setGoalImage] = useState(null);
const [editGoalId, setEditGoalId] = useState(null);
const [editTitle, setEditTitle] = useState('');
const [editTarget, setEditTarget] = useState('');
const [editDeadlineMonths, setEditDeadlineMonths] = useState('');
const goals = userData.goals || [];
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const handleAddGoal = () => {
const t = parseFloat(String(target).replace(',', '.'));
const m = parseInt(months, 10);
if (!title || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) {
Alert.alert('Ошибка', 'Укажите цель, сумму и срок в месяцах.');
return;
}
const deadline = new Date();
deadline.setMonth(deadline.getMonth() + m);
const newGoal = {
id: goal_${Date.now()},
title: title.trim(),
targetAmount: t,
savedAmount: 0,
deadline: deadline.toISOString(),
imageUri: goalImage || null,
};
onUpdateUserData((prev) => ({
...prev,
goals: [...(prev.goals || []), newGoal],
}));
setTitle('');
setTarget('');
setMonths('');
setGoalImage(null);
};
const handleDeposit = (goalId, amount) => {
const n = amount || 0;
onUpdateUserData((prev) => ({
...prev,
goals: (prev.goals || []).map((g) =>
g.id === goalId
? { ...g, savedAmount: (Number(g.savedAmount) || 0) + n }
: g,
),
expenses: [
...(prev.expenses || []),
{
id: save_${Date.now()},
title: 'Перевод в копилку',
amount: n,
category: 'Сбережения',
type: 'expense',
date: new Date().toISOString(),
},
],
}));
};
const handlePickGoalImage = async () => {
const uri = await pickImageFromLibrary();
if (uri) setGoalImage(uri);
};
const handleEditGoalPress = (goal) => {
const now = new Date();
const deadline = goal.deadline ? new Date(goal.deadline) : null;
const monthsLeft =
deadline && !Number.isNaN(deadline.getTime())
? Math.max(
1,
Math.round(
(deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30),
),
)
: '';
textsetEditGoalId(goal.id); setEditTitle(goal.title); setEditTarget(String(goal.targetAmount || '')); setEditDeadlineMonths(monthsLeft ? String(monthsLeft) : '');
};
const handleSaveEditedGoal = () => {
if (!editGoalId) return;
const t = parseFloat(String(editTarget).replace(',', '.'));
const m = parseInt(editDeadlineMonths, 10);
if (!editTitle || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) {
Alert.alert('Ошибка', 'Укажите корректные данные для цели.');
return;
}
const newDeadline = new Date();
newDeadline.setMonth(newDeadline.getMonth() + m);
textonUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).map((g) => g.id === editGoalId ? { ...g, title: editTitle.trim(), targetAmount: t, deadline: newDeadline.toISOString(), } : g, ), })); setEditGoalId(null); setEditTitle(''); setEditTarget(''); setEditDeadlineMonths('');
};
const handleCancelEdit = () => {
setEditGoalId(null);
setEditTitle('');
setEditTarget('');
setEditDeadlineMonths('');
};
const handleDeleteGoal = (goalId) => {
Alert.alert(
'Удалить цель',
'Вы уверены, что хотите удалить эту цель? Прогресс по ней будет потерян.',
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Удалить',
style: 'destructive',
onPress: () => {
onUpdateUserData((prev) => ({
...prev,
goals: (prev.goals || []).filter((g) => g.id !== goalId),
}));
},
},
],
);
};
const now = new Date();
const totalSavedAll = sumBy(goals, (g) => Number(g.savedAmount) || 0);
const totalTargetAll = sumBy(goals, (g) => Number(g.targetAmount) || 0);
const cafeReduce = parseFloat(String(whatIfCafe).replace(',', '.')) || 0;
const extraWeekly = parseFloat(String(whatIfSave).replace(',', '.')) || 0;
const cafeCurrent = sumBy(userData.expenses || [], (e) => {
if (e.type === 'income') return 0;
const c = (e.category || '').toLowerCase();
if (c.includes('еда') || c.includes('кафе')) {
const d = new Date(e.date || new Date());
const key = getMonthKey(d);
if (key === getMonthKey(now)) {
return Number(e.amount) || 0;
}
}
return 0;
});
const cafePotential = cafeCurrent * (cafeReduce / 100);
const whatIfMonthlyExtra = cafePotential + extraWeekly * 4;
return (
<ScrollView
style={[
base.content,
{ backgroundColor: theme.background },
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Блок создания цели */}
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<Text style={[base.cardTitle, { color: theme.text }]}>Цели и копилки</Text>
<View style={[base.pill, { backgroundColor: theme.accentSoft }]}>
<Text style={[base.pillText, { color: theme.accent }]}>💎 Планы</Text>
</View>
</View>
<Text style={[base.tinyText, { color: theme.textMuted }]}>
Всего накоплено: {formatCurrency(totalSavedAll, currency)} из{' '}
{formatCurrency(totalTargetAll || 0, currency)}
</Text>
text<View style={{ marginTop: 12 }}> <TouchableOpacity onPress={handlePickGoalImage} style={[ base.row, { marginBottom: 8, }, ]} > {goalImage ? ( <Image source={{ uri: goalImage }} style={[base.goalImage, { width: 80, height: 80 }]} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}>📷</Text> </View> )} <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото цели (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте визуал — так легче держаться плана. </Text> </View> </TouchableOpacity> <TextInput placeholder="Название цели (например, «Ноутбук»)" placeholderTextColor={theme.textMuted} value={title} onChangeText={setTitle} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={target} onChangeText={setTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={months} onChangeText={setMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <TouchableOpacity onPress={handleAddGoal} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить цель </Text> </TouchableOpacity> </View> </View> {/* Список целей с редактированием и удалением */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Мои цели</Text> </View> {goals.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Создайте первую цель, чтобы следить за прогрессом. </Text> ) : ( goals.map((g) => { const targetAmount = Number(g.targetAmount) || 0; const savedAmount = Number(g.savedAmount) || 0; const pct = targetAmount ? Math.min(100, (savedAmount / targetAmount) * 100) : 0; const deadline = g.deadline ? new Date(g.deadline) : null; const daysLeft = deadline && !Number.isNaN(deadline.getTime()) ? Math.round( (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), ) : null; const left = Math.max(0, targetAmount - savedAmount); const perWeek = daysLeft && daysLeft > 0 ? (left / daysLeft) * 7 : left / 4 || 0; const isEditing = editGoalId === g.id; return ( <View key={g.id} style={{ marginBottom: 12 }}> <View style={base.rowBetween}> <Text style={[base.smallText, { color: theme.text, maxWidth: '70%' }]}> {g.title} </Text> <View style={base.row}> <TouchableOpacity onPress={() => handleEditGoalPress(g)}> <Text style={[base.tinyText, { color: theme.accent, marginRight: 12 }]}> Изм. </Text> </TouchableOpacity> <TouchableOpacity onPress={() => handleDeleteGoal(g.id)}> <Text style={[base.tinyText, { color: theme.danger }]}> Удалить </Text> </TouchableOpacity> </View> </View> {g.imageUri ? ( <Image source={{ uri: g.imageUri }} style={base.goalImage} /> ) : null} <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accent, width: `${pct}%`, }, ]} /> </View> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> {formatCurrency(savedAmount, currency)} из{' '} {formatCurrency(targetAmount, currency)} </Text> {daysLeft !== null && ( <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 4 }, ]} > Осталось ~{daysLeft} дн. </Text> )} </View> {left > 0 && ( <View style={base.rowBetween}> <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 2 }, ]} > Чтобы успеть: ~{formatCurrency(perWeek, currency)} в неделю </Text> <TouchableOpacity onPress={() => handleDeposit(g.id, 10000)} style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 2, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > +10 000 в копилку </Text> </TouchableOpacity> </View> )} {isEditing && ( <View style={{ marginTop: 8, padding: 10, borderRadius: 12, backgroundColor: theme.cardAlt, }} > <Text style={[base.tinyText, { color: theme.textMuted, marginBottom: 4 }]}> Редактирование цели </Text> <TextInput placeholder="Название цели" placeholderTextColor={theme.textMuted} value={editTitle} onChangeText={setEditTitle} style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={editTarget} onChangeText={setEditTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={editDeadlineMonths} onChangeText={setEditDeadlineMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={base.rowBetween}> <TouchableOpacity onPress={handleSaveEditedGoal} style={[ base.button, { flex: 1, backgroundColor: theme.accent, marginTop: 4, marginRight: 4, }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить </Text> </TouchableOpacity> <TouchableOpacity onPress={handleCancelEdit} style={[ base.button, { flex: 1, backgroundColor: theme.card, marginTop: 4, marginLeft: 4, borderWidth: 1, borderColor: theme.border, }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Отмена </Text> </TouchableOpacity> </View> </View> )} <View style={[ base.divider, { backgroundColor: theme.border, marginTop: 8 }, ]} /> </View> ); }) )} </View> {/* Блок «Что если?» */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>«Что если?»</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧪 Симулятор</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Смоделируйте изменения привычек и посмотрите влияние на накопления. </Text> <View style={{ marginTop: 8 }}> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> На сколько % сократить кафе/еду? </Text> <TextInput placeholder="30" placeholderTextColor={theme.textMuted} value={whatIfCafe} onChangeText={setWhatIfCafe} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Доп. отложить в неделю </Text> <TextInput placeholder="10 000" placeholderTextColor={theme.textMuted} value={whatIfSave} onChangeText={setWhatIfSave} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.smallText, { color: theme.text }]}> При таком сценарии вы сможете дополнительно накопить около{' '} {formatCurrency(whatIfMonthlyExtra, currency)} в месяц. </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> За год это будет примерно{' '} {formatCurrency(whatIfMonthlyExtra * 12, currency)} — почти целая отдельная цель 💡 </Text> </View> </View> </View> </ScrollView>
);
};
const AiChatScreen = ({ userData, theme }) => {
const [messages, setMessages] = useState([
{
id: 'm_hello',
from: 'ai',
text: 'Привет! Я твой финансовый ИИ-ассистент. Задавай вопросы: «Как сократить расходы на 20%?», «Как распределить зарплату?», «Что будет, если начать откладывать больше?»',
},
]);
const [input, setInput] = useState('');
const [voiceOn, setVoiceOn] = useState(false);
const sendText = (text) => {
const trimmed = text.trim();
if (!trimmed) return;
const userMsg = {
id: m_${Date.now()}_u,
from: 'user',
text: trimmed,
};
const aiText = generateAiAnswer(trimmed, userData);
const aiMsg = {
id: m_${Date.now()}_ai,
from: 'ai',
text: aiText,
};
setMessages((prev) => [...prev, userMsg, aiMsg]);
setInput('');
if (voiceOn) {
Speech.stop();
Speech.speak(aiText, { language: 'ru-RU' });
}
};
const handleSend = () => {
sendText(input);
};
const quickPrompts = [
'Как сократить расходы на 20%?',
'Как распределить зарплату 300 000?',
'Стоит ли покупать новый телефон?',
'Сделай обзор моих трат.',
];
return (
<KeyboardAvoidingView
style={[
base.content,
{ backgroundColor: theme.background },
]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<Text style={[base.cardTitle, { color: theme.text }]}>ИИ-консультант</Text>
<View style={base.row}>
<Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}>
Голос
</Text>
<Switch
value={voiceOn}
onValueChange={setVoiceOn}
thumbColor={voiceOn ? theme.accent : '#9CA3AF'}
trackColor={{ true: theme.accentSoft, false: theme.cardAlt }}
/>
</View>
</View>
<Text style={[base.tinyText, { color: theme.textMuted }]}>
Ответы основаны на ваших реальных данных: расходах, доходах, целях.
</Text>
<View
style={{
marginTop: 8,
flexDirection: 'row',
flexWrap: 'wrap',
}}
>
{quickPrompts.map((p) => (
<TouchableOpacity
key={p}
onPress={() => sendText(p)}
style={[
base.badge,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.tinyText, { color: theme.text }]}>{p}</Text>
</TouchableOpacity>
))}
</View>
</View>
text<View style={[ base.card, { flex: 1, backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 8 }} showsVerticalScrollIndicator={false} > {messages.map((m) => { const isUser = m.from === 'user'; return ( <View key={m.id} style={[ base.chatBubble, isUser ? base.chatUser : base.chatAi, { backgroundColor: isUser ? theme.accent : theme.cardAlt, }, ]} > <Text style={[ base.tinyText, { color: isUser ? '#FFFFFF' : theme.textMuted }, ]} > {isUser ? 'Вы' : 'ИИ'} </Text> <Text style={[ base.smallText, { color: isUser ? '#FFFFFF' : theme.text, marginTop: 2, }, ]} > {m.text} </Text> </View> ); })} </ScrollView> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, marginTop: 4, }, ]} > <TextInput placeholder="Задайте вопрос по финансам..." placeholderTextColor={theme.textMuted} value={input} onChangeText={setInput} style={[base.inputInline, { color: theme.text }]} multiline /> <TouchableOpacity onPress={handleSend}> <Text style={[base.smallText, { color: theme.accent }]}>Отпр.</Text> </TouchableOpacity> </View> </View> </KeyboardAvoidingView>
);
};
const ProfileScreen = ({
currentUser,
userData,
onUpdateUserData,
theme,
onLogout,
onToggleTheme,
}) => {
const profile = userData.profile || {};
const [firstName, setFirstName] = useState(profile.firstName || '');
const [lastName, setLastName] = useState(profile.lastName || '');
const [phone, setPhone] = useState(profile.phone || '');
const [birthDate, setBirthDate] = useState(profile.birthDate || '');
const [avatarUri, setAvatarUri] = useState(profile.avatarUri || '');
const [monthlyIncome, setMonthlyIncome] = useState(
String((userData.settings && userData.settings.monthlyIncome) || ''),
);
const [weeklyBudget, setWeeklyBudget] = useState(
String((userData.settings && userData.settings.weeklyBudget) || ''),
);
const [currency, setCurrency] = useState(
(userData.settings && userData.settings.currency) || DEFAULT_CURRENCY,
);
const [antiImpulseEnabled, setAntiImpulseEnabled] = useState(
(userData.settings && userData.settings.antiImpulseEnabled) || false,
);
const [antiImpulseThreshold, setAntiImpulseThreshold] = useState(
String((userData.settings && userData.settings.antiImpulseThreshold) || ''),
);
const [pinInput, setPinInput] = useState('');
const [noteDraft, setNoteDraft] = useState('');
const [noteUnlocked, setNoteUnlocked] = useState(false);
useEffect(() => {
const savedEncrypted = userData.secureNote || '';
if (savedEncrypted && currentUser.pin) {
const dec = decryptNote(savedEncrypted, currentUser.pin);
setNoteDraft(dec);
} else if (savedEncrypted && !currentUser.pin) {
setNoteDraft(savedEncrypted);
} else {
setNoteDraft('');
}
}, [userData.secureNote, currentUser.pin]);
useEffect(() => {
const p = userData.profile || {};
setFirstName(p.firstName || '');
setLastName(p.lastName || '');
setPhone(p.phone || '');
setBirthDate(p.birthDate || '');
setAvatarUri(p.avatarUri || '');
}, [userData.profile]);
const handleSaveSettings = () => {
const m = parseFloat(String(monthlyIncome).replace(',', '.')) || 0;
const w = parseFloat(String(weeklyBudget).replace(',', '.')) || 0;
const anti =
parseFloat(String(antiImpulseThreshold).replace(',', '.')) || 0;
textonUpdateUserData((prev) => ({ ...prev, income: m, settings: { ...(prev.settings || {}), monthlyIncome: m, weeklyBudget: w, currency: currency || DEFAULT_CURRENCY, antiImpulseEnabled, antiImpulseThreshold: anti, }, profile: { ...(prev.profile || {}), firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), birthDate: birthDate.trim(), avatarUri: avatarUri || '', }, })); Alert.alert('Сохранено', 'Профиль и бюджет обновлены.');
};
const handleUnlockNote = () => {
if (!currentUser.pin) {
setNoteUnlocked(true);
return;
}
if (pinInput === currentUser.pin) {
const dec = decryptNote(userData.secureNote || '', currentUser.pin);
setNoteDraft(dec);
setNoteUnlocked(true);
} else {
Alert.alert('Ошибка', 'Неверный PIN.');
}
};
const handleSaveNote = () => {
let encrypted = noteDraft;
if (currentUser.pin) {
encrypted = encryptNote(noteDraft, currentUser.pin);
}
onUpdateUserData((prev) => ({
...prev,
secureNote: encrypted,
}));
Alert.alert('Сохранено', 'Заметка зашифрована и сохранена.');
};
const totalSubs = (userData.subscriptions || []).length;
const handlePickAvatar = async () => {
const uri = await pickImageFromLibrary();
if (uri) setAvatarUri(uri);
};
const displayName =
firstName || lastName
? ${firstName} ${lastName}.trim()
: currentUser.email;
return (
<ScrollView
style={[
base.content,
{ backgroundColor: theme.background },
]}
showsVerticalScrollIndicator={false}
>
{/* Профиль и быстрый статус */}
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<View style={base.row}>
<TouchableOpacity onPress={handlePickAvatar}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={base.avatarImage} />
) : (
<View
style={[
base.avatarCircle,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.smallText, { color: theme.textMuted }]}>
{firstName
? firstName.charAt(0).toUpperCase()
: currentUser.email.charAt(0).toUpperCase()}
</Text>
</View>
)}
</TouchableOpacity>
<View style={{ marginLeft: 10 }}>
<Text style={[base.cardTitle, { color: theme.text }]}>
{displayName || 'Пользователь'}
</Text>
<Text style={[base.tinyText, { color: theme.textMuted }]}>
{currentUser.email}
</Text>
</View>
</View>
<TouchableOpacity onPress={onLogout}>
<Text style={[base.smallText, { color: theme.danger }]}>Выйти</Text>
</TouchableOpacity>
</View>
<Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}>
Подписок учтено: {totalSubs} • PIN установлен:{' '}
{currentUser.pin ? 'да' : 'нет'}
</Text>
text<View style={{ marginTop: 12 }}> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} value={phone} onChangeText={setPhone} keyboardType="phone-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> {/* Бюджет и валюта */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Бюджет</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Установите доход, недельный бюджет и валюту — это нужно для индекса «финансовое здоровье» и рекомендаций. </Text> <TextInput placeholder="Доход в месяц" placeholderTextColor={theme.textMuted} value={monthlyIncome} onChangeText={setMonthlyIncome} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Недельный бюджет" placeholderTextColor={theme.textMuted} value={weeklyBudget} onChangeText={setWeeklyBudget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Валюта (например, ₸, ₽, $)" placeholderTextColor={theme.textMuted} value={currency} onChangeText={setCurrency} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TouchableOpacity onPress={handleSaveSettings} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить профиль и бюджет </Text> </TouchableOpacity> </View> {/* Тема */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Тема и вид</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 6 }]}> Тёмная тема </Text> <Switch value={theme.mode === 'dark'} onValueChange={onToggleTheme} thumbColor={theme.mode === 'dark' ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> В будущем сюда можно добавить иконки, виджеты и другие настройки UI. </Text> </View> {/* Анти-импульсная защита */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Контроль импульсных покупок</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧠 Эксперимент</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Включите анти-импульсный режим, чтобы приложение задавало вам «контрольный вопрос» перед крупной покупкой. </Text> <View style={[base.rowBetween, { marginTop: 8 }]}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> Анти-импульсный режим </Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Срабатывает при тратах выше порога. </Text> </View> <Switch value={antiImpulseEnabled} onValueChange={setAntiImpulseEnabled} thumbColor={antiImpulseEnabled ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> <TextInput placeholder="Порог крупной покупки (например, 50 000)" placeholderTextColor={theme.textMuted} value={antiImpulseThreshold} onChangeText={setAntiImpulseThreshold} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> </View> {/* Безопасная заметка */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Безопасная заметка</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔐 Локально</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Заметка шифруется простым алгоритмом (XOR + HEX) с вашим PIN и хранится только в AsyncStorage. Для реального проекта используйте надёжную криптографию (AES и т.п.) и биометрию. </Text> {!noteUnlocked && ( <> {currentUser.pin ? ( <> <TextInput placeholder="Введите PIN для разблокировки" placeholderTextColor={theme.textMuted} value={pinInput} onChangeText={setPinInput} keyboardType="number-pad" secureTextEntry style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleUnlockNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Разблокировать заметку </Text> </TouchableOpacity> </> ) : ( <> <Text style={[base.smallText, { color: theme.text, marginTop: 8 }]}> PIN не установлен при регистрации, поэтому заметка хранится без шифрования. </Text> <TouchableOpacity onPress={() => setNoteUnlocked(true)} style={[ base.button, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Показать заметку </Text> </TouchableOpacity> </> )} </> )} {noteUnlocked && ( <> <TextInput placeholder="Секретная финансовая заметка (пароли сюда лучше не писать)" placeholderTextColor={theme.textMuted} value={noteDraft} onChangeText={setNoteDraft} multiline style={[ base.input, { minHeight: 80, textAlignVertical: 'top', backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleSaveNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить зашифровано </Text> </TouchableOpacity> </> )} </View> </ScrollView>
);
};
const MainApp = ({
currentUser,
userData,
setUserData,
onLogout,
themeMode,
setThemeMode,
}) => {
const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME;
const [activeTab, setActiveTab] = useState('home');
const handleUpdateUserData = (updater) => {
setUserData((prev) => {
const prevVal = prev || createDefaultUserData(themeMode);
const next = typeof updater === 'function' ? updater(prevVal) : updater;
return next;
});
};
const handleToggleTheme = () => {
setThemeMode((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
const profile = userData.profile || {};
const avatarUri = profile.avatarUri;
const displayName =
profile.firstName || profile.lastName
? ${profile.firstName || ''} ${profile.lastName || ''}.trim()
: currentUser.email;
const initials =
profile.firstName || profile.lastName
? ${(profile.firstName || profile.lastName || 'U').charAt(0).toUpperCase()}
: currentUser.email.charAt(0).toUpperCase();
return (
<SafeAreaView
style={[
base.container,
{ backgroundColor: theme.background },
]}
>
<StatusBar
barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'}
/>
<View style={base.header}>
<View style={base.row}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={base.avatarImage} />
) : (
<View
style={[
base.avatarCircle,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.smallText, { color: theme.textMuted }]}>
{initials}
</Text>
</View>
)}
<View style={{ marginLeft: 10 }}>
<Text style={[base.headerTitle, { color: theme.text }]}>
Мой бюджет
</Text>
<Text style={[base.headerSubtitle, { color: theme.textMuted }]}>
{displayName}
</Text>
</View>
</View>
<View style={base.chipRow}>
<View
style={[
base.chip,
{ backgroundColor: theme.accentSoft },
]}
>
<Text style={[base.chipText, { color: theme.accent }]}>🤖 ИИ-чек</Text>
</View>
<TouchableOpacity
onPress={handleToggleTheme}
style={[
base.chip,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.chipText, { color: theme.text }]}>
{theme.mode === 'dark' ? '◐' : '◑'}
</Text>
</TouchableOpacity>
</View>
</View>
text{activeTab === 'home' && <HomeScreen userData={userData} theme={theme} />} {activeTab === 'expenses' && ( <ExpensesScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'goals' && ( <GoalsScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'ai' && <AiChatScreen userData={userData} theme={theme} />} {activeTab === 'profile' && ( <ProfileScreen currentUser={currentUser} userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} onLogout={onLogout} onToggleTheme={handleToggleTheme} /> )} <View style={[ base.tabBar, { backgroundColor: theme.card, borderTopColor: theme.border }, ]} > {[ { id: 'home', label: 'Главная', icon: '🏡' }, { id: 'expenses', label: 'Траты', icon: '💳' }, { id: 'goals', label: 'Цели', icon: '📌' }, { id: 'ai', label: 'ИИ-чат', icon: '🧠' }, { id: 'profile', label: 'Профиль', icon: '👤' }, ].map((tab) => { const isActive = activeTab === tab.id; return ( <TouchableOpacity key={tab.id} onPress={() => setActiveTab(tab.id)} style={[ base.tabButton, { backgroundColor: isActive ? theme.accentSoft : 'transparent' }, ]} > <Text style={[ { fontSize: 16 }, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.icon} </Text> <Text style={[ base.tabLabel, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.label} </Text> </TouchableOpacity> ); })} </View> </SafeAreaView>
);
};
// ---------- корневой компонент ----------
export default function App() {
const [users, setUsers] = useState([]);
const [session, setSession] = useState(null);
const [userData, setUserData] = useState(null);
const [themeMode, setThemeMode] = useState('dark');
const [loading, setLoading] = useState(true);
const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME;
useEffect(() => {
const init = async () => {
try {
const usersJson = await AsyncStorage.getItem(STORAGE_KEYS.USERS);
const sessionJson = await AsyncStorage.getItem(STORAGE_KEYS.SESSION);
const loadedUsers = usersJson ? JSON.parse(usersJson) : [];
setUsers(loadedUsers);
if (sessionJson) {
const s = JSON.parse(sessionJson);
const u = loadedUsers.find((user) => user.id === s.userId);
if (u) {
setSession(s);
const dataKey = FINAPP_DATA_${u.id};
const dataJson = await AsyncStorage.getItem(dataKey);
if (dataJson) {
const parsed = JSON.parse(dataJson);
setUserData(parsed);
if (parsed.settings && parsed.settings.themeMode) {
setThemeMode(parsed.settings.themeMode);
}
} else {
const defaultData = createDefaultUserData(themeMode);
setUserData(defaultData);
await AsyncStorage.setItem(dataKey, JSON.stringify(defaultData));
}
}
}
} catch (e) {
console.warn('Init error', e);
} finally {
setLoading(false);
}
};
init();
}, []);
useEffect(() => {
const persist = async () => {
if (!session || !userData) return;
try {
const key = FINAPP_DATA_${session.userId};
const payload = {
...userData,
settings: {
...(userData.settings || {}),
themeMode,
},
};
await AsyncStorage.setItem(key, JSON.stringify(payload));
} catch (e) {
console.warn('Save userData error', e);
}
};
persist();
}, [userData, session, themeMode]);
const handleLogin = async (user, profileFromRegistration = null) => {
const s = { userId: user.id };
setSession(s);
await AsyncStorage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(s));
const key = FINAPP_DATA_${user.id};
const dataJson = await AsyncStorage.getItem(key);
if (dataJson) {
const parsed = JSON.parse(dataJson);
setUserData(parsed);
if (parsed.settings && parsed.settings.themeMode) {
setThemeMode(parsed.settings.themeMode);
}
} else {
const defaultData = createDefaultUserData(themeMode, profileFromRegistration || {});
setUserData(defaultData);
await AsyncStorage.setItem(key, JSON.stringify(defaultData));
}
};
const handleRegister = async (newUser, profile) => {
const updatedUsers = [...users, newUser];
setUsers(updatedUsers);
await AsyncStorage.setItem(STORAGE_KEYS.USERS, JSON.stringify(updatedUsers));
await handleLogin(newUser, profile);
};
const handleLogout = async () => {
setSession(null);
setUserData(null);
await AsyncStorage.removeItem(STORAGE_KEYS.SESSION);
};
if (loading) {
return (
<SafeAreaView
style={[
base.container,
base.center,
{ backgroundColor: theme.background },
]}
>
<StatusBar
barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'}
/>
<Text style={[base.smallText, { color: theme.textMuted }]}>
Загрузка данных...
</Text>
</SafeAreaView>
);
}
if (!session || !userData) {
return (
<AuthScreen
users={users}
onLogin={handleLogin}
onRegister={handleRegister}
theme={theme}
/>
);
}
const currentUser = users.find((u) => u.id === session.userId) || {
id: session.userId,
email: 'user',
pin: '',
};
return (
<MainApp
currentUser={currentUser}
userData={userData}
setUserData={setUserData}
onLogout={handleLogout}
themeMode={themeMode}
setThemeMode={setThemeMode}
/>
);
}
Вот переписанный под Expo Snack App.js — без expo-mlkit-ocr (OCR заменён на заглушку, чтобы Snack не падал).
В Snack не забудь добавить зависимости:
@react-native-async-storage/async-storage,expo-image-picker,expo-speech.
jsximport React, { useEffect, useState } from 'react'; import { SafeAreaView, View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView, FlatList, StatusBar, KeyboardAvoidingView, Platform, Switch, Alert, Image, ActivityIndicator, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as ImagePicker from 'expo-image-picker'; import * as Speech from 'expo-speech'; // expo-mlkit-ocr в Snack недоступен, поэтому импорт убран // import ExpoMlkitOcr from 'expo-mlkit-ocr'; // ---------- константы ---------- const STORAGE_KEYS = { USERS: 'FINAPP_USERS', SESSION: 'FINAPP_SESSION', }; const DARK_THEME = { mode: 'dark', background: '#020617', card: '#020617', cardAlt: '#0B1220', accent: '#22C55E', accentSoft: 'rgba(34,197,94,0.22)', text: '#F9FAFB', textMuted: '#9CA3AF', border: '#1F2937', danger: '#EF4444', success: '#22C55E', warning: '#EAB308', }; const LIGHT_THEME = { mode: 'light', background: '#F3F4F6', card: '#FFFFFF', cardAlt: '#E5E7EB', accent: '#0F766E', accentSoft: 'rgba(15,118,110,0.16)', text: '#111827', textMuted: '#6B7280', border: '#D1D5DB', danger: '#DC2626', success: '#16A34A', warning: '#CA8A04', }; const DEFAULT_CURRENCY = '₸'; const DEFAULT_CATEGORIES = [ 'Еда', 'Транспорт', 'Жильё', 'Здоровье', 'Подписки', 'Игры/развлечения', 'Покупки', 'Сбережения', 'Другое', ]; // ---------- стили ---------- const base = StyleSheet.create({ flex1: { flex: 1 }, center: { justifyContent: 'center', alignItems: 'center' }, container: { flex: 1 }, authContainer: { flex: 1, paddingHorizontal: 24, paddingTop: 32, paddingBottom: 24 }, title: { fontSize: 28, fontWeight: '700', marginBottom: 4 }, subtitle: { fontSize: 14, marginBottom: 24 }, input: { borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, fontSize: 14, marginBottom: 12, }, button: { borderRadius: 18, paddingVertical: 12, alignItems: 'center', justifyContent: 'center', marginTop: 4, }, buttonText: { fontSize: 15, fontWeight: '600' }, link: { fontSize: 14, marginTop: 12, textAlign: 'center' }, errorText: { fontSize: 13, marginBottom: 8 }, header: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, headerTitle: { fontSize: 22, fontWeight: '700' }, headerSubtitle: { fontSize: 12 }, chipRow: { flexDirection: 'row', alignItems: 'center' }, chip: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999, marginLeft: 8, }, chipText: { fontSize: 12 }, content: { flex: 1, paddingHorizontal: 16, paddingTop: 4, paddingBottom: 4, }, card: { borderRadius: 20, padding: 16, marginBottom: 14, shadowColor: '#000', shadowOpacity: 0.14, shadowRadius: 12, shadowOffset: { width: 0, height: 4 }, elevation: 4, }, cardHeaderRow: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 6, }, cardTitle: { fontSize: 16, fontWeight: '600' }, cardSubtitle: { fontSize: 12 }, row: { flexDirection: 'row', alignItems: 'center' }, rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, pill: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, marginLeft: 6, }, pillText: { fontSize: 11, fontWeight: '500' }, tabBar: { flexDirection: 'row', paddingHorizontal: 6, paddingVertical: 8, borderTopWidth: StyleSheet.hairlineWidth, }, tabButton: { flex: 1, alignItems: 'center', paddingVertical: 8, borderRadius: 999, marginHorizontal: 4, }, tabLabel: { fontSize: 11, marginTop: 2 }, badgeDot: { width: 8, height: 8, borderRadius: 999, marginRight: 4, }, smallText: { fontSize: 12 }, tinyText: { fontSize: 11 }, sectionTitle: { fontSize: 14, fontWeight: '600', marginBottom: 8 }, inputRow: { flexDirection: 'row', alignItems: 'center', borderRadius: 14, paddingHorizontal: 10, marginBottom: 8, }, inputInline: { flex: 1, paddingVertical: 6, fontSize: 13 }, expenseItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10, }, divider: { height: StyleSheet.hairlineWidth, marginVertical: 6, }, tag: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, marginRight: 6, marginTop: 4, }, tagText: { fontSize: 11 }, chartRow: { marginTop: 6 }, chartBarBg: { height: 10, borderRadius: 999, overflow: 'hidden', }, chartBarFill: { height: 10, borderRadius: 999, }, chatBubble: { padding: 10, borderRadius: 14, marginVertical: 4, maxWidth: '85%', }, chatUser: { alignSelf: 'flex-end' }, chatAi: { alignSelf: 'flex-start' }, badge: { paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, marginRight: 6, marginBottom: 6, }, avatarCircle: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', }, avatarImage: { width: 44, height: 44, borderRadius: 22, }, goalImage: { width: '100%', height: 120, borderRadius: 12, marginTop: 6, }, }); // ---------- helpers ---------- const formatCurrency = (amount, currency = DEFAULT_CURRENCY) => { const n = Number.isFinite(amount) ? amount : 0; const rounded = Math.round(n); return `${currency} ${rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}`; }; const formatDate = (isoString) => { if (!isoString) return ''; const d = new Date(isoString); if (Number.isNaN(d.getTime())) return ''; return d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short', }); }; const getMonthKey = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; const guessCategory = (title) => { if (!title) return 'Другое'; const t = title.toLowerCase(); if (/(super|market|магазин|магнит|кошелек|grocery|food|еда|dodo|kfc|burger)/.test(t)) return 'Еда'; if (/(аптека|pharm|apteka|drug)/.test(t)) return 'Здоровье'; if (/(taxi|такси|yandex go|bolt|uber|bus|metro)/.test(t)) return 'Транспорт'; if (/(аренда|rent|ipotek|ипотека|квартира)/.test(t)) return 'Жильё'; if (/(netflix|spotify|youtube|yandex plus|подписка|subscription)/.test(t)) return 'Подписки'; if (/(steam|psn|xbox|game|игр)/.test(t)) return 'Игры/развлечения'; if (/(cafe|кафе|coffee|кофе|restaurant|ресторан)/.test(t)) return 'Еда'; if (/(ozon|wildberries|wb|aliexpress)/.test(t)) return 'Покупки'; return 'Другое'; }; const sumBy = (arr, fn) => arr.reduce((acc, item, idx) => acc + (fn(item, idx) || 0), 0); const buildMonthlyTotals = (expenses) => { const totals = {}; expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); const key = getMonthKey(d); totals[key] = (totals[key] || 0) + (Number(e.amount) || 0); }); return totals; }; const buildCategoryTotalsForMonth = (expenses, monthKey) => { const m = {}; expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); if (getMonthKey(d) !== monthKey) return; const cat = e.category || 'Другое'; m[cat] = (m[cat] || 0) + (Number(e.amount) || 0); }); return m; }; const forecastNextMonth = (expenses) => { const totalsMap = buildMonthlyTotals(expenses); const keys = Object.keys(totalsMap).sort(); if (keys.length === 0) return 0; const ys = keys.map((k) => totalsMap[k]); if (keys.length === 1) return ys[0]; const n = ys.length; let sumX = 0; let sumY = 0; let sumXY = 0; let sumX2 = 0; for (let i = 0; i < n; i += 1) { const x = i; const y = ys[i]; sumX += x; sumY += y; sumXY += x * y; sumX2 += x * x; } const denom = n * sumX2 - sumX * sumX; const slope = denom === 0 ? 0 : (n * sumXY - sumX * sumY) / denom; const intercept = (sumY - slope * sumX) / n; const forecast = intercept + slope * n; return forecast > 0 ? forecast : ys[ys.length - 1]; }; const detectAnomalies = (expenses) => { if (!expenses.length) return []; const amounts = expenses.map((e) => Number(e.amount) || 0); const avg = sumBy(amounts, (a) => a) / amounts.length; const threshold = Math.max(avg * 2, 0); return expenses.filter((e) => (Number(e.amount) || 0) > threshold && threshold > 0); }; const calculateDailyStreak = (expenses, monthlyIncome) => { if (!expenses.length || !monthlyIncome) return 0; const dailyBudget = monthlyIncome / 30; if (dailyBudget <= 0) return 0; const byDay = {}; expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); const key = d.toISOString().substring(0, 10); byDay[key] = (byDay[key] || 0) + (Number(e.amount) || 0); }); const today = new Date(); let streak = 0; for (let i = 0; i < 365; i += 1) { const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000); const key = d.toISOString().substring(0, 10); const spent = byDay[key] || 0; if (spent <= dailyBudget) { streak += 1; } else { break; } } return streak; }; const countNoSpendDaysThisMonth = (expenses) => { if (!expenses.length) return 0; const now = new Date(); const year = now.getFullYear(); const month = now.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const spentDays = new Set(); expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); if (d.getFullYear() !== year || d.getMonth() !== month) return; spentDays.add(d.getDate()); }); return daysInMonth - spentDays.size; }; const calculateFinancialHealth = (userData) => { const expenses = userData.expenses || []; const settings = userData.settings || {}; const monthlyIncome = Number(settings.monthlyIncome) || Number(userData.income) || 0; const now = new Date(); const currentMonthKey = getMonthKey(now); const monthTotals = buildMonthlyTotals(expenses); const totalThisMonth = monthTotals[currentMonthKey] || 0; const savings = Math.max(0, monthlyIncome - totalThisMonth); const savingsRate = monthlyIncome > 0 ? savings / monthlyIncome : 0; const unnecessary = sumBy(expenses, (e) => { if (e.type === 'income') return 0; const d = new Date(e.date || new Date()); if (getMonthKey(d) !== currentMonthKey) return 0; const c = (e.category || '').toLowerCase(); if ( c.includes('игр') || c.includes('развлеч') || c.includes('подпис') || c.includes('покуп') || c.includes('кафе') || c.includes('bar') || c.includes('club') ) { return Number(e.amount) || 0; } return 0; }); const unnecessaryShare = monthlyIncome > 0 ? unnecessary / monthlyIncome : 0; let score = 50; score += Math.min(20, savingsRate * 40); score -= Math.min(15, unnecessaryShare * 30); if (userData.goals && userData.goals.length) score += 5; if (expenses.length > 20) score += 5; score = Math.max(0, Math.min(100, Math.round(score))); let label = 'Нужно больше данных'; if (score >= 80) { label = 'Отличное состояние'; } else if (score >= 60) { label = 'Хорошее состояние'; } else if (score >= 40) { label = 'Среднее, есть над чем поработать'; } else { label = 'Слабое, нужен план'; } return { score, label, monthlyIncome, totalThisMonth, savings, unnecessary, }; }; // простое шифрование заметок по PIN (XOR + hex, не для продакшена) const encryptNote = (plain, pin) => { if (!plain) return ''; if (!pin) return plain; const digits = pin.split('').map((d) => parseInt(d, 10) || 0); if (!digits.length) return plain; let out = ''; for (let i = 0; i < plain.length; i += 1) { const code = plain.charCodeAt(i) ^ digits[i % digits.length]; out += code.toString(16).padStart(2, '0'); } return out; }; const decryptNote = (cipher, pin) => { if (!cipher) return ''; if (!pin) return cipher; const digits = pin.split('').map((d) => parseInt(d, 10) || 0); if (!digits.length) return cipher; try { let out = ''; for (let i = 0; i < cipher.length; i += 2) { const hex = cipher.substr(i, 2); const code = parseInt(hex, 16) ^ digits[(i / 2) % digits.length]; out += String.fromCharCode(code); } return out; } catch (e) { return ''; } }; const buildSmartReminders = (userData) => { const reminders = []; const expenses = userData.expenses || []; const settings = userData.settings || {}; const now = new Date(); const nowKey = getMonthKey(now); const last7Total = sumBy(expenses, (e) => { if (e.type === 'income') return 0; const d = new Date(e.date || new Date()); const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24); if (diff >= 0 && diff <= 7) { return Number(e.amount) || 0; } return 0; }); const weeklyBudget = Number(settings.weeklyBudget) || 0; if (weeklyBudget > 0 && last7Total > weeklyBudget) { reminders.push( `За последние 7 дней вы уже вышли за недельный бюджет на ${formatCurrency( last7Total - weeklyBudget, settings.currency || DEFAULT_CURRENCY, )}.`, ); } else if (weeklyBudget > 0 && last7Total > weeklyBudget * 0.8) { reminders.push('Вы приблизились к недельному бюджету — самое время притормозить расходы.'); } (userData.goals || []).forEach((g) => { const target = Number(g.targetAmount) || 0; const saved = Number(g.savedAmount) || 0; if (!target) return; const d = g.deadline ? new Date(g.deadline) : null; const left = target - saved; if (left <= 0 || !d) return; const daysLeft = Math.round((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); if (daysLeft <= 0) return; const perDay = left / daysLeft; if (perDay > 0) { reminders.push( `До цели «${g.title}» осталось ${formatCurrency( left, settings.currency || DEFAULT_CURRENCY, )}. Нужно откладывать примерно ${formatCurrency( perDay, settings.currency || DEFAULT_CURRENCY, )} в день.`, ); } }); const monthTotals = buildMonthlyTotals(expenses); const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); const prevKey = getMonthKey(prevMonth); const totalNow = monthTotals[nowKey] || 0; const totalPrev = monthTotals[prevKey] || 0; if (totalPrev > 0 && totalNow > totalPrev * 1.15) { const diff = totalNow - totalPrev; reminders.push( `В этом месяце ваши траты уже на ${formatCurrency( diff, settings.currency || DEFAULT_CURRENCY, )} больше, чем в прошлом.`, ); } if (!reminders.length) { reminders.push('Вы молодец — критичных напоминаний сейчас нет 🎉'); } return reminders; }; const buildInsightsSummary = (userData) => { const expenses = userData.expenses || []; const settings = userData.settings || {}; const now = new Date(); const currentMonthKey = getMonthKey(now); const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); const prevKey = getMonthKey(prevMonth); const monthTotals = buildMonthlyTotals(expenses); const totalNow = monthTotals[currentMonthKey] || 0; const totalPrev = monthTotals[prevKey] || 0; const catNow = buildCategoryTotalsForMonth(expenses, currentMonthKey); const catPrev = buildCategoryTotalsForMonth(expenses, prevKey); const entriesNow = Object.entries(catNow); const topCat = entriesNow.sort((a, b) => b[1] - a[1])[0]; const currency = settings.currency || DEFAULT_CURRENCY; const anomalies = detectAnomalies(expenses).slice(0, 3); const lines = []; if (totalPrev > 0 && totalNow > 0) { const delta = totalNow - totalPrev; const pct = (delta / totalPrev) * 100; if (Math.abs(pct) >= 5) { if (pct > 0) { lines.push( `Общие расходы выросли на ${pct.toFixed(1)}% по сравнению с прошлым месяцем (${formatCurrency( totalNow, currency, )} против ${formatCurrency(totalPrev, currency)}).`, ); } else { lines.push( `Вы тратите на ${Math.abs(pct).toFixed( 1, )}% меньше, чем в прошлом месяце — отличный прогресс 👏`, ); } } } if (topCat) { lines.push( `Больше всего вы тратите на категорию «${topCat[0]}» — ${formatCurrency( topCat[1], currency, )} в этом месяце.`, ); } Object.keys(catNow).forEach((c) => { const nowVal = catNow[c]; const prevVal = catPrev[c] || 0; if (prevVal <= 0) return; const pct = (nowVal - prevVal) / prevVal; if (pct >= 0.25 && nowVal > 0) { lines.push( `Категория «${c}» выросла на ${Math.round( pct * 100, )}% относительно прошлого месяца — стоит проверить, можно ли там сэкономить.`, ); } }); if (anomalies.length) { const txt = anomalies .map((a) => `"${a.title}" на ${formatCurrency(a.amount, currency)}`) .join(', '); lines.push(`Обнаружены разовые крупные траты: ${txt}. Убедитесь, что они действительно нужны.`); } if (!lines.length) { lines.push('Пока недостаточно данных для глубокого анализа. Начните добавлять траты 🙂'); } return lines; }; const generateAiAnswer = (promptRaw, userData) => { const prompt = promptRaw.toLowerCase(); const health = calculateFinancialHealth(userData); const insights = buildInsightsSummary(userData); const currency = (userData.settings && userData.settings.currency) || DEFAULT_CURRENCY; const expenses = userData.expenses || []; const monthlyTotals = buildMonthlyTotals(expenses); const nowKey = getMonthKey(new Date()); const totalThisMonth = monthlyTotals[nowKey] || 0; if (!expenses.length && !prompt.includes('регистрац')) { return 'У меня пока нет ваших данных. Добавьте несколько трат и доходов — тогда я смогу давать персональные советы.'; } if (prompt.includes('сократить') || prompt.includes('эконом') || prompt.includes('сэкономить')) { const catNow = buildCategoryTotalsForMonth(expenses, nowKey); const entries = Object.entries(catNow).sort((a, b) => b[1] - a[1]); const top3 = entries.slice(0, 3); const tips = top3 .map( ([c, v]) => `• «${c}» — ${formatCurrency( v, currency, )} в месяц. Попробуйте снизить хотя бы на 10–20%.`, ) .join('\n'); return ( `Сейчас ваши траты за месяц: ${formatCurrency( totalThisMonth, currency, )}. Чтобы сократить их, лучше всего начать с самых крупных категорий:\n\n${tips}\n\n` + 'Дополнительно:\n' + '• установите недельный бюджет и фиксируйте все траты;\n' + '• избегайте спонтанных покупок — делайте «паузу 24 часа»;\n' + '• для подписок — оставьте только то, чем пользуетесь каждую неделю.' ); } if (prompt.includes('как распределить') || prompt.includes('зарплат')) { const match = promptRaw.match(/(\d[\d\s]{2,})/); const income = match ? parseInt(match[1].replace(/\s/g, ''), 10) : health.monthlyIncome || 0; if (!income) { return 'Напишите, пожалуйста, размер зарплаты (например: «Как распределить зарплату 300 000?»), или задайте месячный доход в настройках. Тогда я предложу детальный план распределения.'; } const needs = income * 0.5; const wants = income * 0.3; const saving = income * 0.2; return ( `Классическая модель 50/30/20 для зарплаты ${formatCurrency(income, currency)}:\n\n` + `• 50% (${formatCurrency(needs, currency)}) — обязательные расходы (жильё, еда, транспорт, кредиты);\n` + `• 30% (${formatCurrency(wants, currency)}) — «хотелки» и развлечения;\n` + `• 20% (${formatCurrency(saving, currency)}) — сбережения и цели.\n\n` + 'Вы можете адаптировать: если есть кредиты — уменьшите «хотелки» до 10–15% и направьте разницу на досрочное погашение и накопления.' ); } if (prompt.includes('стоит ли покупать') || prompt.includes('покупать')) { return ( 'Задайте себе три вопроса перед покупкой:\n' + '1) Нужна ли вещь прямо сейчас или это импульс?\n' + '2) Сколько часов вашей работы она стоит?\n' + '3) Через месяц вы будете так же рады этой покупке?\n\n' + 'Если сумма покупки >10% от месячного дохода — хорошо бы выдержать «паузу 24 часа». ' + 'Также посмотрите, не мешает ли эта покупка вашим финансовым целям (накоплениям и т.п.).' ); } if (prompt.includes('что если') || prompt.includes('если буду')) { return ( 'Функция «Что если?» уже доступна на вкладке «Цели» — там можно смоделировать, что будет, если сократить траты на кафе или начать откладывать больше. ' + 'Если хотите, сформулируйте сценарий конкретно (например: «Что будет, если каждый месяц откладывать на 20 000 больше?»), и я прикину эффект в чате.' ); } const baseInsights = insights.slice(0, 2).join('\n\n'); return ( `Краткий обзор вашего финансового состояния:\n` + `• Индекс финансового здоровья: ${health.score}/100 (${health.label}).\n` + `• Траты в этом месяце: ${formatCurrency(totalThisMonth, currency)}.\n` + `• Потенциальные сбережения: ${formatCurrency(health.savings, currency)}.\n\n` + `${baseInsights}\n\n` + 'Задавайте мне конкретные вопросы: например, «Как сократить расходы на 20%?» или ' + '«Помоги перераспределить бюджет с учётом цели на 800 000».' ); }; // helper для дефолтных данных пользователя const createDefaultUserData = (themeMode, profile = {}) => ({ income: 0, expenses: [], goals: [], subscriptions: [], settings: { monthlyIncome: 0, weeklyBudget: 0, currency: DEFAULT_CURRENCY, antiImpulseEnabled: false, antiImpulseThreshold: 0, themeMode: themeMode || 'dark', }, profile, secureNote: '', }); // helper для выбора картинки из галереи const pickImageFromLibrary = async () => { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { Alert.alert('Нет доступа', 'Разрешите доступ к фото в настройках устройства.'); return null; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8, }); if (result.canceled) return null; const asset = result.assets && result.assets[0]; if (!asset) return null; return asset.uri; }; // ---------- OCR + ИИ-проверка чеков ---------- // На Expo Snack/Expo Go нельзя использовать expo-mlkit-ocr (нужен Dev Client), // поэтому здесь заглушка. Можно вернуть тестовый текст для проверки парсера. const runOcrOnReceiptImage = async (uri) => { console.log('runOcrOnReceiptImage (stub on Snack). uri:', uri); // Например, для теста можно раскомментировать: // return 'ИТОГ 1 234,56'; return ''; }; // «Умный» парсер текста чека: пытается понять именно итоговую сумму const parseReceiptOcrText = (text) => { if (!text) return null; const lines = text .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); if (!lines.length) return null; // Денежный паттерн: 1 234,56 / 1234.56 / 12 345 / 12345 и т.п. const MONEY_REGEX = /\d{1,3}(?:[ \u00A0]\d{3})*(?:[.,]\d{1,2})?|\d+[.,]\d{1,2}/g; const parseMoney = (raw) => { if (!raw) return NaN; let cleaned = String(raw) .replace(/\u00A0/g, ' ') .replace(/[^\d.,\s]/g, '') .replace(/\s+/g, ' ') .trim(); if (!cleaned) return NaN; cleaned = cleaned.replace(/\s/g, ''); if (cleaned.includes(',') && cleaned.includes('.')) { const lastComma = cleaned.lastIndexOf(','); const lastDot = cleaned.lastIndexOf('.'); if (lastComma > lastDot) { cleaned = cleaned.replace(/\./g, '').replace(',', '.'); } else { cleaned = cleaned.replace(/,/g, ''); } } else if (cleaned.includes(',')) { cleaned = cleaned.replace(',', '.'); } const value = parseFloat(cleaned); return Number.isFinite(value) ? value : NaN; }; const isTotalLine = (line) => { const lower = line.toLowerCase().replace(/\s+/g, ' '); return ( /итог(о)?/.test(lower) || /к оплате/.test(lower) || /сумма к оплате/.test(lower) || /итог по чеку/.test(lower) || /total/.test(lower) ); }; let amount = null; // 1) Ищем строку с ИТОГ / К ОПЛАТЕ и берём сумму оттуда for (const line of lines) { if (!isTotalLine(line)) continue; const matches = line.match(MONEY_REGEX); if (matches && matches.length) { const raw = matches[matches.length - 1]; const v = parseMoney(raw); if (Number.isFinite(v) && v > 0) { amount = v; break; } } } // 2) Если не нашли — смотрим последние 3–5 строк if (amount == null) { const tail = lines.slice(-5); const candidates = []; tail.forEach((line) => { const matches = line.match(MONEY_REGEX); if (!matches) return; matches.forEach((m) => { const v = parseMoney(m); if (Number.isFinite(v) && v > 0 && v < 1e7) { candidates.push(v); } }); }); if (candidates.length) { amount = Math.max(...candidates); } } // 3) Совсем fallback — ищем по всему тексту if (amount == null) { const candidates = []; lines.forEach((line) => { const matches = line.match(MONEY_REGEX); if (!matches) return; matches.forEach((m) => { const v = parseMoney(m); if (Number.isFinite(v) && v > 0 && v < 1e7) { candidates.push(v); } }); }); if (candidates.length) { amount = Math.max(...candidates); } } // 4) Название магазина / покупки let titleLine = ''; for (const line of lines) { const letters = line.replace(/[^A-Za-zА-Яа-яЁё]/g, ''); if (!letters.length) continue; if (isTotalLine(line)) continue; if (/кассир|смена|чек|фискальн|ккм|рнк|зн/i.test(line)) continue; titleLine = line; break; } if (!titleLine) { titleLine = 'Покупка по чеку'; } const category = guessCategory(titleLine); return { title: titleLine, amount: Number.isFinite(amount) && amount > 0 ? amount : 0, category, }; }; // ---------- небольшие компоненты ---------- const CategoryBarChart = ({ data, theme }) => { if (!data || !data.length) return null; const max = Math.max(...data.map((d) => d.value || 0)); if (!max) return null; return ( <View style={base.chartRow}> {data.map((item) => { const pct = (item.value / max) * 100; return ( <View key={item.label} style={{ marginBottom: 6 }}> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted }]}>{item.label}</Text> <Text style={[base.tinyText, { color: theme.text }]}> {formatCurrency(item.value)} </Text> </View> <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 3 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accent, width: `${pct}%`, }, ]} /> </View> </View> ); })} </View> ); }; const SimpleMonthlyChart = ({ expenses, theme }) => { const totalsMap = buildMonthlyTotals(expenses || []); const keys = Object.keys(totalsMap).sort(); if (!keys.length) return null; const items = keys.slice(-6).map((k) => ({ key: k, value: totalsMap[k] })); const max = Math.max(...items.map((i) => i.value)); if (!max) return null; return ( <View style={{ marginTop: 4 }}> {items.map((item) => { const [year, month] = item.key.split('-'); const label = `${month}.${year.slice(-2)}`; const pct = (item.value / max) * 100; return ( <View key={item.key} style={{ marginBottom: 4 }}> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted }]}>{label}</Text> <Text style={[base.tinyText, { color: theme.text }]}> {formatCurrency(item.value)} </Text> </View> <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 3 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accentSoft, width: `${pct}%`, }, ]} /> </View> </View> ); })} </View> ); }; const SpendingGauge = ({ monthlyIncome, spent, theme, currency }) => { if (!monthlyIncome) return null; const ratio = monthlyIncome > 0 ? spent / monthlyIncome : 0; const pct = Math.max(0, Math.min(1, ratio)); const labelPct = (pct * 100).toFixed(0); return ( <View style={{ marginTop: 10 }}> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Расходы от дохода</Text> <Text style={[base.tinyText, { color: theme.text }]}> {labelPct}% ({formatCurrency(spent, currency)}) </Text> </View> <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 4, height: 12 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: ratio > 1 ? theme.danger : theme.accent, width: `${pct * 100}%`, }, ]} /> </View> {ratio > 1 && ( <Text style={[base.tinyText, { color: theme.danger, marginTop: 4 }]}> Вы вышли за рамки месячного дохода — стоит пересмотреть план. </Text> )} </View> ); }; // ---------- экраны ---------- const AuthScreen = ({ users, onLogin, onRegister, theme }) => { const [mode, setMode] = useState('login'); // 'login' | 'register' const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [pin, setPin] = useState(''); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [phone, setPhone] = useState(''); const [birthDate, setBirthDate] = useState(''); const [avatarUri, setAvatarUri] = useState(''); const [error, setError] = useState(''); const toggleMode = () => { setError(''); setMode((m) => (m === 'login' ? 'register' : 'login')); }; const handleLoginPress = () => { setError(''); const normalizedEmail = email.trim().toLowerCase(); if (!normalizedEmail || !password) { setError('Заполните email и пароль.'); return; } const user = users.find((u) => u.email === normalizedEmail); if (!user || user.password !== password) { setError('Неверный email или пароль.'); return; } onLogin(user); }; const handleRegisterPress = () => { setError(''); const normalizedEmail = email.trim().toLowerCase(); if (!normalizedEmail || !password) { setError('Заполните email и пароль.'); return; } if (!normalizedEmail.includes('@')) { setError('Некорректный email.'); return; } if (password.length < 4) { setError('Пароль должен быть не короче 4 символов.'); return; } if (!firstName.trim()) { setError('Укажите имя.'); return; } if (!phone.trim()) { setError('Укажите номер телефона.'); return; } if (users.some((u) => u.email === normalizedEmail)) { setError('Пользователь с таким email уже существует.'); return; } const newUser = { id: `u_${Date.now()}`, email: normalizedEmail, password, pin: pin || '', createdAt: new Date().toISOString(), }; const profile = { firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), birthDate: birthDate.trim(), avatarUri: avatarUri || '', }; onRegister(newUser, profile); }; const handlePickAvatar = async () => { const uri = await pickImageFromLibrary(); if (uri) { setAvatarUri(uri); } }; return ( <SafeAreaView style={[ base.container, { backgroundColor: theme.background }, ]} > <StatusBar barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'} /> <KeyboardAvoidingView style={base.flex1} behavior={Platform.OS === 'ios' ? 'padding' : undefined} > <View style={[base.authContainer]}> <View style={{ marginBottom: 32 }}> <Text style={[base.title, { color: theme.text }]}> Финансовый ИИ-помощник </Text> <Text style={[base.subtitle, { color: theme.textMuted }]}> Управляй доходами, расходами, целями и сканируй чеки с ИИ. </Text> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[ base.cardTitle, { color: theme.text }, ]} > {mode === 'login' ? 'Вход' : 'Регистрация'} </Text> <View style={[ base.pill, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.pillText, { color: theme.accent }, ]} > ⚡ ИИ + чек-сканер </Text> </View> </View> {error ? ( <Text style={[base.errorText, { color: theme.danger }]}>{error}</Text> ) : null} {mode === 'register' && ( <> <View style={base.row}> <TouchableOpacity onPress={handlePickAvatar}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> + </Text> </View> )} </TouchableOpacity> <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото профиля (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте аватар, чтобы приложение стало более личным. </Text> </View> </View> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} keyboardType="phone-pad" value={phone} onChangeText={setPhone} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </> )} <TextInput placeholder="Email" placeholderTextColor={theme.textMuted} autoCapitalize="none" keyboardType="email-address" value={email} onChangeText={setEmail} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Пароль" placeholderTextColor={theme.textMuted} secureTextEntry value={password} onChangeText={setPassword} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> {mode === 'register' && ( <TextInput placeholder="PIN (необязательно, для шифрования заметок)" placeholderTextColor={theme.textMuted} value={pin} onChangeText={setPin} keyboardType="number-pad" maxLength={6} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> )} <TouchableOpacity onPress={mode === 'login' ? handleLoginPress : handleRegisterPress} style={[ base.button, { backgroundColor: theme.accent, marginTop: 8 }, ]} > <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {mode === 'login' ? 'Войти' : 'Зарегистрироваться'} </Text> </TouchableOpacity> <TouchableOpacity onPress={toggleMode}> <Text style={[ base.link, { color: theme.textMuted }, ]} > {mode === 'login' ? 'Нет аккаунта? Создать' : 'Уже есть аккаунт? Войти'} </Text> </TouchableOpacity> </View> <View style={{ marginTop: 24 }}> <Text style={[ base.smallText, { color: theme.textMuted }, ]} > Данные хранятся только локально на устройстве (AsyncStorage). Для реального приложения стоит добавить серверный бэкап и более серьёзную криптографию. </Text> </View> </View> </KeyboardAvoidingView> </SafeAreaView> ); }; const HomeScreen = ({ userData, theme }) => { const health = calculateFinancialHealth(userData); const insights = buildInsightsSummary(userData); const reminders = buildSmartReminders(userData); const expenses = userData.expenses || []; const settings = userData.settings || {}; const currency = settings.currency || DEFAULT_CURRENCY; const now = new Date(); const currentMonthKey = getMonthKey(now); const catNowMap = buildCategoryTotalsForMonth(expenses, currentMonthKey); const categoryData = Object.entries(catNowMap) .map(([label, value]) => ({ label, value })) .sort((a, b) => b.value - a.value); const forecast = forecastNextMonth(expenses); const streak = calculateDailyStreak( expenses, Number(settings.monthlyIncome) || Number(userData.income) || 0, ); const noSpendDays = countNoSpendDaysThisMonth(expenses); const profile = userData.profile || {}; const firstName = profile.firstName || ''; const badges = []; if (health.score >= 80) badges.push('Экономист месяца'); if (streak >= 7) badges.push('7 дней в плюсе'); if (noSpendDays >= 5) badges.push('Дни без трат'); if (expenses.length >= 50) badges.push('Финансовый самурай'); return ( <ScrollView style={[ base.content, { backgroundColor: theme.background }, ]} showsVerticalScrollIndicator={false} > <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.rowBetween}> <View style={{ flex: 1 }}> <Text style={[base.cardSubtitle, { color: theme.textMuted }]}> Индекс финансового здоровья </Text> <Text style={[ { fontSize: 32, fontWeight: '700', marginTop: 4 }, { color: theme.text }, ]} > {health.score} <Text style={{ fontSize: 16 }}>/100</Text> </Text> {firstName ? ( <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 4 }, ]} > {firstName}, это ваш персональный индекс. </Text> ) : null} </View> <View style={[ { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 16, }, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.tinyText, { color: theme.textMuted }]}>Статус</Text> <Text style={[ base.smallText, { color: theme.text, fontWeight: '600', marginTop: 2 }, ]} > {health.label} </Text> </View> </View> <View style={{ marginTop: 10 }}> <View style={base.rowBetween}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Доход / мес.</Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {health.monthlyIncome ? formatCurrency(health.monthlyIncome, currency) : 'Не задан'} </Text> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Траты в этом месяце </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(health.totalThisMonth, currency)} </Text> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Сбережения</Text> <Text style={[ base.smallText, { color: health.savings >= 0 ? theme.success : theme.danger, marginTop: 2, }, ]} > {formatCurrency(health.savings, currency)} </Text> </View> <SpendingGauge monthlyIncome={health.monthlyIncome} spent={health.totalThisMonth} theme={theme} currency={currency} /> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Привычки</Text> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Серия дней в рамках бюджета: {streak} </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Дней без трат в этом месяце: {noSpendDays} </Text> </View> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Бейджи</Text> <View style={[base.row, { flexWrap: 'wrap', marginTop: 4 }]}> {badges.length ? ( badges.map((b) => ( <View key={b} style={[ base.badge, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.tinyText, { color: theme.accent }, ]} > ✨ {b} </Text> </View> )) ) : ( <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Собирайте награды, держась в рамках бюджета. </Text> )} </View> </View> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>AI-инсайты</Text> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>🧩 Анализ</Text> </View> </View> {insights.slice(0, 3).map((line, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{line}</Text> </View> ))} </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Категории</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Текущий месяц </Text> </View> <CategoryBarChart data={categoryData} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Динамика</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Прогноз: {formatCurrency(forecast, currency)} </Text> </View> <SimpleMonthlyChart expenses={expenses} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Умные напоминания</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔔</Text> </View> {reminders.map((r, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{r}</Text> </View> ))} </View> </ScrollView> ); }; const ExpensesScreen = ({ userData, onUpdateUserData, theme }) => { const [title, setTitle] = useState(''); const [amount, setAmount] = useState(''); const [category, setCategory] = useState(''); const [isIncome, setIsIncome] = useState(false); const [isScanning, setIsScanning] = useState(false); const [lastReceiptPreview, setLastReceiptPreview] = useState(null); // { uri, text } const expenses = [...(userData.expenses || [])].sort( (a, b) => new Date(b.date || 0).getTime() - new Date(a.date || 0).getTime(), ); const settings = userData.settings || {}; const currency = settings.currency || DEFAULT_CURRENCY; const antiImpulseEnabled = settings.antiImpulseEnabled; const antiImpulseThreshold = Number(settings.antiImpulseThreshold) || 0; const addEntry = (n, guessed, customTitle, extraMeta) => { const entry = { id: `exp_${Date.now()}`, title: (customTitle || title).trim(), amount: n, category: guessed, type: isIncome ? 'income' : 'expense', date: new Date().toISOString(), ...(extraMeta ? { meta: extraMeta } : {}), }; onUpdateUserData((prev) => ({ ...prev, expenses: [...(prev.expenses || []), entry], })); setTitle(''); setAmount(''); setCategory(''); setIsIncome(false); }; const handleAdd = () => { const n = parseFloat(String(amount).replace(',', '.')); if (!title || !Number.isFinite(n) || n <= 0) { Alert.alert('Ошибка', 'Введите название и сумму.'); return; } const guessed = category || guessCategory(title); if (!isIncome && antiImpulseEnabled && antiImpulseThreshold > 0 && n >= antiImpulseThreshold) { Alert.alert( 'Анти-импульсная защита', `Покупка на ${formatCurrency( n, currency, )} превышает ваш лимит в ${formatCurrency(antiImpulseThreshold, currency)}.\n\n` + 'Вы уверены, что хотите продолжить?', [ { text: 'Отменить', style: 'cancel' }, { text: 'Купить', style: 'destructive', onPress: () => addEntry(n, guessed), }, ], ); return; } addEntry(n, guessed); }; const handleScanReceipt = async () => { try { setIsScanning(true); const perm = await ImagePicker.requestCameraPermissionsAsync(); if (!perm.granted) { Alert.alert('Нет доступа', 'Разрешите доступ к камере в настройках устройства.'); return; } const result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8, }); if (result.canceled) return; const asset = result.assets && result.assets[0]; if (!asset) return; const uri = asset.uri; const ocrText = await runOcrOnReceiptImage(uri); setLastReceiptPreview({ uri, text: ocrText }); const parsed = parseReceiptOcrText(ocrText); if (!parsed || !parsed.amount || parsed.amount <= 0) { Alert.alert( 'Не удалось распознать чек', 'В Snack OCR отключён — сумма не распознана. Введите её вручную при добавлении траты.', ); return; } const guessedCategory = parsed.category || guessCategory(parsed.title); const n = parsed.amount; // Авто-добавление в расходы onUpdateUserData((prev) => ({ ...prev, expenses: [ ...(prev.expenses || []), { id: `exp_receipt_${Date.now()}`, title: parsed.title || 'Покупка по чеку', amount: n, category: guessedCategory, type: 'expense', date: new Date().toISOString(), meta: { source: 'receipt', ocrText, imageUri: uri, }, }, ], })); Alert.alert( 'Чек добавлен', `Мы распознали сумму ${formatCurrency( n, currency, )} и категорию «${guessedCategory}».\nЗапись добавлена в историю расходов.`, ); } catch (e) { console.warn('Receipt scan error', e); Alert.alert('Ошибка', 'Не удалось обработать чек. Попробуйте ещё раз.'); } finally { setIsScanning(false); } }; const totalExpenses = sumBy(expenses, (e) => e.type === 'income' ? 0 : Number(e.amount) || 0, ); const totalIncome = sumBy(expenses, (e) => e.type === 'income' ? Number(e.amount) || 0 : 0, ); return ( <View style={[ base.content, { backgroundColor: theme.background }, ]} > <ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > {/* ИИ-проверка чеков */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <View style={{ flex: 1, paddingRight: 6 }}> <Text style={[base.cardTitle, { color: theme.text }]}> ИИ-проверка чеков </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 2 }]}> Сделайте фото чека — в полноценном приложении мы бы распознали сумму и автоматически добавили расход. </Text> </View> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>🤖 Чек-сканер</Text> </View> </View> <TouchableOpacity onPress={handleScanReceipt} disabled={isScanning} style={[ base.button, { backgroundColor: isScanning ? theme.cardAlt : theme.accent, marginTop: 8, flexDirection: 'row', }, ]} > {isScanning && ( <ActivityIndicator size="small" style={{ marginRight: 8 }} color="#FFFFFF" /> )} <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {isScanning ? 'Сканируем чек...' : 'Сканировать чек (демо)'} </Text> </TouchableOpacity> {lastReceiptPreview && ( <View style={[base.row, { marginTop: 10 }]}> <Image source={{ uri: lastReceiptPreview.uri }} style={{ width: 56, height: 56, borderRadius: 12, marginRight: 8 }} /> <View style={{ flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Последний чек (OCR) </Text> <Text numberOfLines={3} style={[base.smallText, { color: theme.text, marginTop: 2 }]} > {lastReceiptPreview.text || 'Текст не распознан / заглушка'} </Text> </View> </View> )} </View> {/* Ручное добавление записи */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Новая запись</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}> Доход </Text> <Switch value={isIncome} onValueChange={setIsIncome} thumbColor={isIncome ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Магазин / описание" placeholderTextColor={theme.textMuted} value={title} onChangeText={(text) => { setTitle(text); if (!category) { setCategory(guessCategory(text)); } }} style={[base.inputInline, { color: theme.text }]} /> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} keyboardType="numeric" value={amount} onChangeText={setAmount} style={[base.inputInline, { color: theme.text }]} /> <Text style={[base.smallText, { color: theme.textMuted }]}>{currency}</Text> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Категория</Text> <ScrollView horizontal showsHorizontalScrollIndicator={false}> {DEFAULT_CATEGORIES.map((c) => ( <TouchableOpacity key={c} onPress={() => setCategory(c)} style={[ base.tag, { backgroundColor: category === c ? theme.accentSoft : theme.cardAlt, borderWidth: 1, borderColor: category === c ? theme.accent : theme.border, }, ]} > <Text style={[ base.tagText, { color: category === c ? theme.accent : theme.textMuted, }, ]} > {c} </Text> </TouchableOpacity> ))} </ScrollView> </View> <TouchableOpacity onPress={handleAdd} style={[ base.button, { backgroundColor: theme.accent, marginTop: 12 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить </Text> </TouchableOpacity> </View> {/* Итоги */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.rowBetween}> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего расходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalExpenses, currency)} </Text> </View> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего доходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalIncome, currency)} </Text> </View> </View> </View> {/* История */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>История</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> {expenses.length} записей </Text> </View> {expenses.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Добавьте первую трату или доход. </Text> ) : ( <FlatList data={expenses} keyExtractor={(item) => item.id} scrollEnabled={false} renderItem={({ item }) => { const isImpulseHighlight = item.type !== 'income' && antiImpulseEnabled && antiImpulseThreshold > 0 && Number(item.amount) >= antiImpulseThreshold; const isReceipt = item.meta && item.meta.source === 'receipt'; return ( <View> <View style={base.expenseItem}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> {item.title} </Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted }]}> {formatDate(item.date)} </Text> <View style={[ base.tag, { backgroundColor: theme.cardAlt, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.textMuted }, ]} > {item.category || 'Другое'} </Text> </View> {isReceipt && ( <View style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > ИИ-чек </Text> </View> )} </View> </View> <Text style={[ base.smallText, { color: item.type === 'income' ? theme.success : isImpulseHighlight ? theme.danger : theme.text, }, ]} > {item.type === 'income' ? '+' : '-'} {formatCurrency(item.amount, currency)} </Text> </View> <View style={[ base.divider, { backgroundColor: theme.border }, ]} /> </View> ); }} /> )} </View> </ScrollView> </View> ); }; const GoalsScreen = ({ userData, onUpdateUserData, theme }) => { const [title, setTitle] = useState(''); const [target, setTarget] = useState(''); const [months, setMonths] = useState(''); const [whatIfCafe, setWhatIfCafe] = useState('30'); const [whatIfSave, setWhatIfSave] = useState('10000'); const [goalImage, setGoalImage] = useState(null); const [editGoalId, setEditGoalId] = useState(null); const [editTitle, setEditTitle] = useState(''); const [editTarget, setEditTarget] = useState(''); const [editDeadlineMonths, setEditDeadlineMonths] = useState(''); const goals = userData.goals || []; const settings = userData.settings || {}; const currency = settings.currency || DEFAULT_CURRENCY; const handleAddGoal = () => { const t = parseFloat(String(target).replace(',', '.')); const m = parseInt(months, 10); if (!title || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) { Alert.alert('Ошибка', 'Укажите цель, сумму и срок в месяцах.'); return; } const deadline = new Date(); deadline.setMonth(deadline.getMonth() + m); const newGoal = { id: `goal_${Date.now()}`, title: title.trim(), targetAmount: t, savedAmount: 0, deadline: deadline.toISOString(), imageUri: goalImage || null, }; onUpdateUserData((prev) => ({ ...prev, goals: [...(prev.goals || []), newGoal], })); setTitle(''); setTarget(''); setMonths(''); setGoalImage(null); }; const handleDeposit = (goalId, amount) => { const n = amount || 0; onUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).map((g) => g.id === goalId ? { ...g, savedAmount: (Number(g.savedAmount) || 0) + n } : g, ), expenses: [ ...(prev.expenses || []), { id: `save_${Date.now()}`, title: 'Перевод в копилку', amount: n, category: 'Сбережения', type: 'expense', date: new Date().toISOString(), }, ], })); }; const handlePickGoalImage = async () => { const uri = await pickImageFromLibrary(); if (uri) setGoalImage(uri); }; const handleEditGoalPress = (goal) => { const now = new Date(); const deadline = goal.deadline ? new Date(goal.deadline) : null; const monthsLeft = deadline && !Number.isNaN(deadline.getTime()) ? Math.max( 1, Math.round( (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30), ), ) : ''; setEditGoalId(goal.id); setEditTitle(goal.title); setEditTarget(String(goal.targetAmount || '')); setEditDeadlineMonths(monthsLeft ? String(monthsLeft) : ''); }; const handleSaveEditedGoal = () => { if (!editGoalId) return; const t = parseFloat(String(editTarget).replace(',', '.')); const m = parseInt(editDeadlineMonths, 10); if (!editTitle || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) { Alert.alert('Ошибка', 'Укажите корректные данные для цели.'); return; } const newDeadline = new Date(); newDeadline.setMonth(newDeadline.getMonth() + m); onUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).map((g) => g.id === editGoalId ? { ...g, title: editTitle.trim(), targetAmount: t, deadline: newDeadline.toISOString(), } : g, ), })); setEditGoalId(null); setEditTitle(''); setEditTarget(''); setEditDeadlineMonths(''); }; const handleCancelEdit = () => { setEditGoalId(null); setEditTitle(''); setEditTarget(''); setEditDeadlineMonths(''); }; const handleDeleteGoal = (goalId) => { Alert.alert( 'Удалить цель', 'Вы уверены, что хотите удалить эту цель? Прогресс по ней будет потерян.', [ { text: 'Отмена', style: 'cancel' }, { text: 'Удалить', style: 'destructive', onPress: () => { onUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).filter((g) => g.id !== goalId), })); }, }, ], ); }; const now = new Date(); const totalSavedAll = sumBy(goals, (g) => Number(g.savedAmount) || 0); const totalTargetAll = sumBy(goals, (g) => Number(g.targetAmount) || 0); const cafeReduce = parseFloat(String(whatIfCafe).replace(',', '.')) || 0; const extraWeekly = parseFloat(String(whatIfSave).replace(',', '.')) || 0; const cafeCurrent = sumBy(userData.expenses || [], (e) => { if (e.type === 'income') return 0; const c = (e.category || '').toLowerCase(); if (c.includes('еда') || c.includes('кафе')) { const d = new Date(e.date || new Date()); const key = getMonthKey(d); if (key === getMonthKey(now)) { return Number(e.amount) || 0; } } return 0; }); const cafePotential = cafeCurrent * (cafeReduce / 100); const whatIfMonthlyExtra = cafePotential + extraWeekly * 4; return ( <ScrollView style={[ base.content, { backgroundColor: theme.background }, ]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" > {/* Блок создания цели */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Цели и копилки</Text> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>💎 Планы</Text> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего накоплено: {formatCurrency(totalSavedAll, currency)} из{' '} {formatCurrency(totalTargetAll || 0, currency)} </Text> <View style={{ marginTop: 12 }}> <TouchableOpacity onPress={handlePickGoalImage} style={[ base.row, { marginBottom: 8, }, ]} > {goalImage ? ( <Image source={{ uri: goalImage }} style={[base.goalImage, { width: 80, height: 80 }]} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}>📷</Text> </View> )} <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото цели (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте визуал — так легче держаться плана. </Text> </View> </TouchableOpacity> <TextInput placeholder="Название цели (например, «Ноутбук»)" placeholderTextColor={theme.textMuted} value={title} onChangeText={setTitle} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={target} onChangeText={setTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={months} onChangeText={setMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <TouchableOpacity onPress={handleAddGoal} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить цель </Text> </TouchableOpacity> </View> </View> {/* Список целей с редактированием и удалением */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Мои цели</Text> </View> {goals.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Создайте первую цель, чтобы следить за прогрессом. </Text> ) : ( goals.map((g) => { const targetAmount = Number(g.targetAmount) || 0; const savedAmount = Number(g.savedAmount) || 0; const pct = targetAmount ? Math.min(100, (savedAmount / targetAmount) * 100) : 0; const deadline = g.deadline ? new Date(g.deadline) : null; const daysLeft = deadline && !Number.isNaN(deadline.getTime()) ? Math.round( (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), ) : null; const left = Math.max(0, targetAmount - savedAmount); const perWeek = daysLeft && daysLeft > 0 ? (left / daysLeft) * 7 : left / 4 || 0; const isEditing = editGoalId === g.id; return ( <View key={g.id} style={{ marginBottom: 12 }}> <View style={base.rowBetween}> <Text style={[base.smallText, { color: theme.text, maxWidth: '70%' }]}> {g.title} </Text> <View style={base.row}> <TouchableOpacity onPress={() => handleEditGoalPress(g)}> <Text style={[base.tinyText, { color: theme.accent, marginRight: 12 }]}> Изм. </Text> </TouchableOpacity> <TouchableOpacity onPress={() => handleDeleteGoal(g.id)}> <Text style={[base.tinyText, { color: theme.danger }]}> Удалить </Text> </TouchableOpacity> </View> </View> {g.imageUri ? ( <Image source={{ uri: g.imageUri }} style={base.goalImage} /> ) : null} <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accent, width: `${pct}%`, }, ]} /> </View> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> {formatCurrency(savedAmount, currency)} из{' '} {formatCurrency(targetAmount, currency)} </Text> {daysLeft !== null && ( <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 4 }, ]} > Осталось ~{daysLeft} дн. </Text> )} </View> {left > 0 && ( <View style={base.rowBetween}> <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 2 }, ]} > Чтобы успеть: ~{formatCurrency(perWeek, currency)} в неделю </Text> <TouchableOpacity onPress={() => handleDeposit(g.id, 10000)} style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 2, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > +10 000 в копилку </Text> </TouchableOpacity> </View> )} {isEditing && ( <View style={{ marginTop: 8, padding: 10, borderRadius: 12, backgroundColor: theme.cardAlt, }} > <Text style={[base.tinyText, { color: theme.textMuted, marginBottom: 4 }]}> Редактирование цели </Text> <TextInput placeholder="Название цели" placeholderTextColor={theme.textMuted} value={editTitle} onChangeText={setEditTitle} style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={editTarget} onChangeText={setEditTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={editDeadlineMonths} onChangeText={setEditDeadlineMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={base.rowBetween}> <TouchableOpacity onPress={handleSaveEditedGoal} style={[ base.button, { flex: 1, backgroundColor: theme.accent, marginTop: 4, marginRight: 4, }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить </Text> </TouchableOpacity> <TouchableOpacity onPress={handleCancelEdit} style={[ base.button, { flex: 1, backgroundColor: theme.card, marginTop: 4, marginLeft: 4, borderWidth: 1, borderColor: theme.border, }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Отмена </Text> </TouchableOpacity> </View> </View> )} <View style={[ base.divider, { backgroundColor: theme.border, marginTop: 8 }, ]} /> </View> ); }) )} </View> {/* Блок «Что если?» */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>«Что если?»</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧪 Симулятор</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Смоделируйте изменения привычек и посмотрите влияние на накопления. </Text> <View style={{ marginTop: 8 }}> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> На сколько % сократить кафе/еду? </Text> <TextInput placeholder="30" placeholderTextColor={theme.textMuted} value={whatIfCafe} onChangeText={setWhatIfCafe} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Доп. отложить в неделю </Text> <TextInput placeholder="10 000" placeholderTextColor={theme.textMuted} value={whatIfSave} onChangeText={setWhatIfSave} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.smallText, { color: theme.text }]}> При таком сценарии вы сможете дополнительно накопить около{' '} {formatCurrency(whatIfMonthlyExtra, currency)} в месяц. </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> За год это будет примерно{' '} {formatCurrency(whatIfMonthlyExtra * 12, currency)} — почти целая отдельная цель 💡 </Text> </View> </View> </View> </ScrollView> ); }; const AiChatScreen = ({ userData, theme }) => { const [messages, setMessages] = useState([ { id: 'm_hello', from: 'ai', text: 'Привет! Я твой финансовый ИИ-ассистент. Задавай вопросы: «Как сократить расходы на 20%?», «Как распределить зарплату?», «Что будет, если начать откладывать больше?»', }, ]); const [input, setInput] = useState(''); const [voiceOn, setVoiceOn] = useState(false); const sendText = (text) => { const trimmed = text.trim(); if (!trimmed) return; const userMsg = { id: `m_${Date.now()}_u`, from: 'user', text: trimmed, }; const aiText = generateAiAnswer(trimmed, userData); const aiMsg = { id: `m_${Date.now()}_ai`, from: 'ai', text: aiText, }; setMessages((prev) => [...prev, userMsg, aiMsg]); setInput(''); if (voiceOn) { Speech.stop(); Speech.speak(aiText, { language: 'ru-RU' }); } }; const handleSend = () => { sendText(input); }; const quickPrompts = [ 'Как сократить расходы на 20%?', 'Как распределить зарплату 300 000?', 'Стоит ли покупать новый телефон?', 'Сделай обзор моих трат.', ]; return ( <KeyboardAvoidingView style={[ base.content, { backgroundColor: theme.background }, ]} behavior={Platform.OS === 'ios' ? 'padding' : undefined} > <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>ИИ-консультант</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}> Голос </Text> <Switch value={voiceOn} onValueChange={setVoiceOn} thumbColor={voiceOn ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Ответы основаны на ваших реальных данных: расходах, доходах, целях. </Text> <View style={{ marginTop: 8, flexDirection: 'row', flexWrap: 'wrap', }} > {quickPrompts.map((p) => ( <TouchableOpacity key={p} onPress={() => sendText(p)} style={[ base.badge, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.tinyText, { color: theme.text }]}>{p}</Text> </TouchableOpacity> ))} </View> </View> <View style={[ base.card, { flex: 1, backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 8 }} showsVerticalScrollIndicator={false} > {messages.map((m) => { const isUser = m.from === 'user'; return ( <View key={m.id} style={[ base.chatBubble, isUser ? base.chatUser : base.chatAi, { backgroundColor: isUser ? theme.accent : theme.cardAlt, }, ]} > <Text style={[ base.tinyText, { color: isUser ? '#FFFFFF' : theme.textMuted }, ]} > {isUser ? 'Вы' : 'ИИ'} </Text> <Text style={[ base.smallText, { color: isUser ? '#FFFFFF' : theme.text, marginTop: 2, }, ]} > {m.text} </Text> </View> ); })} </ScrollView> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, marginTop: 4, }, ]} > <TextInput placeholder="Задайте вопрос по финансам..." placeholderTextColor={theme.textMuted} value={input} onChangeText={setInput} style={[base.inputInline, { color: theme.text }]} multiline /> <TouchableOpacity onPress={handleSend}> <Text style={[base.smallText, { color: theme.accent }]}>Отпр.</Text> </TouchableOpacity> </View> </View> </KeyboardAvoidingView> ); }; const ProfileScreen = ({ currentUser, userData, onUpdateUserData, theme, onLogout, onToggleTheme, }) => { const profile = userData.profile || {}; const [firstName, setFirstName] = useState(profile.firstName || ''); const [lastName, setLastName] = useState(profile.lastName || ''); const [phone, setPhone] = useState(profile.phone || ''); const [birthDate, setBirthDate] = useState(profile.birthDate || ''); const [avatarUri, setAvatarUri] = useState(profile.avatarUri || ''); const [monthlyIncome, setMonthlyIncome] = useState( String((userData.settings && userData.settings.monthlyIncome) || ''), ); const [weeklyBudget, setWeeklyBudget] = useState( String((userData.settings && userData.settings.weeklyBudget) || ''), ); const [currency, setCurrency] = useState( (userData.settings && userData.settings.currency) || DEFAULT_CURRENCY, ); const [antiImpulseEnabled, setAntiImpulseEnabled] = useState( (userData.settings && userData.settings.antiImpulseEnabled) || false, ); const [antiImpulseThreshold, setAntiImpulseThreshold] = useState( String((userData.settings && userData.settings.antiImpulseThreshold) || ''), ); const [pinInput, setPinInput] = useState(''); const [noteDraft, setNoteDraft] = useState(''); const [noteUnlocked, setNoteUnlocked] = useState(false); useEffect(() => { const savedEncrypted = userData.secureNote || ''; if (savedEncrypted && currentUser.pin) { const dec = decryptNote(savedEncrypted, currentUser.pin); setNoteDraft(dec); } else if (savedEncrypted && !currentUser.pin) { setNoteDraft(savedEncrypted); } else { setNoteDraft(''); } }, [userData.secureNote, currentUser.pin]); useEffect(() => { const p = userData.profile || {}; setFirstName(p.firstName || ''); setLastName(p.lastName || ''); setPhone(p.phone || ''); setBirthDate(p.birthDate || ''); setAvatarUri(p.avatarUri || ''); }, [userData.profile]); const handleSaveSettings = () => { const m = parseFloat(String(monthlyIncome).replace(',', '.')) || 0; const w = parseFloat(String(weeklyBudget).replace(',', '.')) || 0; const anti = parseFloat(String(antiImpulseThreshold).replace(',', '.')) || 0; onUpdateUserData((prev) => ({ ...prev, income: m, settings: { ...(prev.settings || {}), monthlyIncome: m, weeklyBudget: w, currency: currency || DEFAULT_CURRENCY, antiImpulseEnabled, antiImpulseThreshold: anti, }, profile: { ...(prev.profile || {}), firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), birthDate: birthDate.trim(), avatarUri: avatarUri || '', }, })); Alert.alert('Сохранено', 'Профиль и бюджет обновлены.'); }; const handleUnlockNote = () => { if (!currentUser.pin) { setNoteUnlocked(true); return; } if (pinInput === currentUser.pin) { const dec = decryptNote(userData.secureNote || '', currentUser.pin); setNoteDraft(dec); setNoteUnlocked(true); } else { Alert.alert('Ошибка', 'Неверный PIN.'); } }; const handleSaveNote = () => { let encrypted = noteDraft; if (currentUser.pin) { encrypted = encryptNote(noteDraft, currentUser.pin); } onUpdateUserData((prev) => ({ ...prev, secureNote: encrypted, })); Alert.alert('Сохранено', 'Заметка зашифрована и сохранена.'); }; const totalSubs = (userData.subscriptions || []).length; const handlePickAvatar = async () => { const uri = await pickImageFromLibrary(); if (uri) setAvatarUri(uri); }; const displayName = firstName || lastName ? `${firstName} ${lastName}`.trim() : currentUser.email; return ( <ScrollView style={[ base.content, { backgroundColor: theme.background }, ]} showsVerticalScrollIndicator={false} > {/* Профиль и быстрый статус */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <View style={base.row}> <TouchableOpacity onPress={handlePickAvatar}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> {firstName ? firstName.charAt(0).toUpperCase() : currentUser.email.charAt(0).toUpperCase()} </Text> </View> )} </TouchableOpacity> <View style={{ marginLeft: 10 }}> <Text style={[base.cardTitle, { color: theme.text }]}> {displayName || 'Пользователь'} </Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> {currentUser.email} </Text> </View> </View> <TouchableOpacity onPress={onLogout}> <Text style={[base.smallText, { color: theme.danger }]}>Выйти</Text> </TouchableOpacity> </View> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Подписок учтено: {totalSubs} • PIN установлен:{' '} {currentUser.pin ? 'да' : 'нет'} </Text> <View style={{ marginTop: 12 }}> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} value={phone} onChangeText={setPhone} keyboardType="phone-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> {/* Бюджет и валюта */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Бюджет</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Установите доход, недельный бюджет и валюту — это нужно для индекса «финансовое здоровье» и рекомендаций. </Text> <TextInput placeholder="Доход в месяц" placeholderTextColor={theme.textMuted} value={monthlyIncome} onChangeText={setMonthlyIncome} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Недельный бюджет" placeholderTextColor={theme.textMuted} value={weeklyBudget} onChangeText={setWeeklyBudget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Валюта (например, ₸, ₽, $)" placeholderTextColor={theme.textMuted} value={currency} onChangeText={setCurrency} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TouchableOpacity onPress={handleSaveSettings} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить профиль и бюджет </Text> </TouchableOpacity> </View> {/* Тема */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Тема и вид</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 6 }]}> Тёмная тема </Text> <Switch value={theme.mode === 'dark'} onValueChange={onToggleTheme} thumbColor={theme.mode === 'dark' ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> В будущем сюда можно добавить иконки, виджеты и другие настройки UI. </Text> </View> {/* Анти-импульсная защита */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Контроль импульсных покупок</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧠 Эксперимент</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Включите анти-импульсный режим, чтобы приложение задавало вам «контрольный вопрос» перед крупной покупкой. </Text> <View style={[base.rowBetween, { marginTop: 8 }]}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> Анти-импульсный режим </Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Срабатывает при тратах выше порога. </Text> </View> <Switch value={antiImpulseEnabled} onValueChange={setAntiImpulseEnabled} thumbColor={antiImpulseEnabled ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> <TextInput placeholder="Порог крупной покупки (например, 50 000)" placeholderTextColor={theme.textMuted} value={antiImpulseThreshold} onChangeText={setAntiImpulseThreshold} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> </View> {/* Безопасная заметка */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Безопасная заметка</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔐 Локально</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Заметка шифруется простым алгоритмом (XOR + HEX) с вашим PIN и хранится только в AsyncStorage. Для реального проекта используйте надёжную криптографию (AES и т.п.) и биометрию. </Text> {!noteUnlocked && ( <> {currentUser.pin ? ( <> <TextInput placeholder="Введите PIN для разблокировки" placeholderTextColor={theme.textMuted} value={pinInput} onChangeText={setPinInput} keyboardType="number-pad" secureTextEntry style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleUnlockNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Разблокировать заметку </Text> </TouchableOpacity> </> ) : ( <> <Text style={[base.smallText, { color: theme.text, marginTop: 8 }]}> PIN не установлен при регистрации, поэтому заметка хранится без шифрования. </Text> <TouchableOpacity onPress={() => setNoteUnlocked(true)} style={[ base.button, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Показать заметку </Text> </TouchableOpacity> </> )} </> )} {noteUnlocked && ( <> <TextInput placeholder="Секретная финансовая заметка (пароли сюда лучше не писать)" placeholderTextColor={theme.textMuted} value={noteDraft} onChangeText={setNoteDraft} multiline style={[ base.input, { minHeight: 80, textAlignVertical: 'top', backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleSaveNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить зашифровано </Text> </TouchableOpacity> </> )} </View> </ScrollView> ); }; const MainApp = ({ currentUser, userData, setUserData, onLogout, themeMode, setThemeMode, }) => { const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME; const [activeTab, setActiveTab] = useState('home'); const handleUpdateUserData = (updater) => { setUserData((prev) => { const prevVal = prev || createDefaultUserData(themeMode); const next = typeof updater === 'function' ? updater(prevVal) : updater; return next; }); }; const handleToggleTheme = () => { setThemeMode((prev) => (prev === 'dark' ? 'light' : 'dark')); }; const profile = userData.profile || {}; const avatarUri = profile.avatarUri; const displayName = profile.firstName || profile.lastName ? `${profile.firstName || ''} ${profile.lastName || ''}`.trim() : currentUser.email; const initials = profile.firstName || profile.lastName ? `${(profile.firstName || profile.lastName || 'U').charAt(0).toUpperCase()}` : currentUser.email.charAt(0).toUpperCase(); return ( <SafeAreaView style={[ base.container, { backgroundColor: theme.background }, ]} > <StatusBar barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'} /> <View style={base.header}> <View style={base.row}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> {initials} </Text> </View> )} <View style={{ marginLeft: 10 }}> <Text style={[base.headerTitle, { color: theme.text }]}> Мой бюджет </Text> <Text style={[base.headerSubtitle, { color: theme.textMuted }]}> {displayName} </Text> </View> </View> <View style={base.chipRow}> <View style={[ base.chip, { backgroundColor: theme.accentSoft }, ]} > <Text style={[base.chipText, { color: theme.accent }]}>🤖 ИИ-чек</Text> </View> <TouchableOpacity onPress={handleToggleTheme} style={[ base.chip, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.chipText, { color: theme.text }]}> {theme.mode === 'dark' ? '◐' : '◑'} </Text> </TouchableOpacity> </View> </View> {activeTab === 'home' && <HomeScreen userData={userData} theme={theme} />} {activeTab === 'expenses' && ( <ExpensesScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'goals' && ( <GoalsScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'ai' && <AiChatScreen userData={userData} theme={theme} />} {activeTab === 'profile' && ( <ProfileScreen currentUser={currentUser} userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} onLogout={onLogout} onToggleTheme={handleToggleTheme} /> )} <View style={[ base.tabBar, { backgroundColor: theme.card, borderTopColor: theme.border }, ]} > {[ { id: 'home', label: 'Главная', icon: '🏡' }, { id: 'expenses', label: 'Траты', icon: '💳' }, { id: 'goals', label: 'Цели', icon: '📌' }, { id: 'ai', label: 'ИИ-чат', icon: '🧠' }, { id: 'profile', label: 'Профиль', icon: '👤' }, ].map((tab) => { const isActive = activeTab === tab.id; return ( <TouchableOpacity key={tab.id} onPress={() => setActiveTab(tab.id)} style={[ base.tabButton, { backgroundColor: isActive ? theme.accentSoft : 'transparent' }, ]} > <Text style={[ { fontSize: 16 }, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.icon} </Text> <Text style={[ base.tabLabel, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.label} </Text> </TouchableOpacity> ); })} </View> </SafeAreaView> ); }; // ---------- корневой компонент ---------- export default function App() { const [users, setUsers] = useState([]); const [session, setSession] = useState(null); const [userData, setUserData] = useState(null); const [themeMode, setThemeMode] = useState('dark'); const [loading, setLoading] = useState(true); const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME; useEffect(() => { const init = async () => { try { const usersJson = await AsyncStorage.getItem(STORAGE_KEYS.USERS); const sessionJson = await AsyncStorage.getItem(STORAGE_KEYS.SESSION); const loadedUsers = usersJson ? JSON.parse(usersJson) : []; setUsers(loadedUsers); if (sessionJson) { const s = JSON.parse(sessionJson); const u = loadedUsers.find((user) => user.id === s.userId); if (u) { setSession(s); const dataKey = `FINAPP_DATA_${u.id}`; const dataJson = await AsyncStorage.getItem(dataKey); if (dataJson) { const parsed = JSON.parse(dataJson); setUserData(parsed); if (parsed.settings && parsed.settings.themeMode) { setThemeMode(parsed.settings.themeMode); } } else { const defaultData = createDefaultUserData(themeMode); setUserData(defaultData); await AsyncStorage.setItem(dataKey, JSON.stringify(defaultData)); } } } } catch (e) { console.warn('Init error', e); } finally { setLoading(false); } }; init(); }, []); useEffect(() => { const persist = async () => { if (!session || !userData) return; try { const key = `FINAPP_DATA_${session.userId}`; const payload = { ...userData, settings: { ...(userData.settings || {}), themeMode, }, }; await AsyncStorage.setItem(key, JSON.stringify(payload)); } catch (e) { console.warn('Save userData error', e); } }; persist(); }, [userData, session, themeMode]); const handleLogin = async (user, profileFromRegistration = null) => { const s = { userId: user.id }; setSession(s); await AsyncStorage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(s)); const key = `FINAPP_DATA_${user.id}`; const dataJson = await AsyncStorage.getItem(key); if (dataJson) { const parsed = JSON.parse(dataJson); setUserData(parsed); if (parsed.settings && parsed.settings.themeMode) { setThemeMode(parsed.settings.themeMode); } } else { const defaultData = createDefaultUserData(themeMode, profileFromRegistration || {}); setUserData(defaultData); await AsyncStorage.setItem(key, JSON.stringify(defaultData)); } }; const handleRegister = async (newUser, profile) => { const updatedUsers = [...users, newUser]; setUsers(updatedUsers); await AsyncStorage.setItem(STORAGE_KEYS.USERS, JSON.stringify(updatedUsers)); await handleLogin(newUser, profile); }; const handleLogout = async () => { setSession(null); setUserData(null); await AsyncStorage.removeItem(STORAGE_KEYS.SESSION); }; if (loading) { return ( <SafeAreaView style={[ base.container, base.center, { backgroundColor: theme.background }, ]} > <StatusBar barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'} /> <Text style={[base.smallText, { color: theme.textMuted }]}> Загрузка данных... </Text> </SafeAreaView> ); } if (!session || !userData) { return ( <AuthScreen users={users} onLogin={handleLogin} onRegister={handleRegister} theme={theme} /> ); } const currentUser = users.find((u) => u.id === session.userId) || { id: session.userId, email: 'user', pin: '', }; return ( <MainApp currentUser={currentUser} userData={userData} setUserData={setUserData} onLogout={handleLogout} themeMode={themeMode} setThemeMode={setThemeMode} /> ); }
у меня snack expo dev нужно полностью переписать твой App.js под Snack отправь полный код исправленный import React, { useEffect, useState } from 'react';
import {
SafeAreaView,
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
FlatList,
StatusBar,
KeyboardAvoidingView,
Platform,
Switch,
Alert,
Image,
ActivityIndicator,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as ImagePicker from 'expo-image-picker';
import * as Speech from 'expo-speech';
import ExpoMlkitOcr from 'expo-mlkit-ocr'; // 👈 OCR на базе Google ML Kit
// ---------- константы ----------
const STORAGE_KEYS = {
USERS: 'FINAPP_USERS',
SESSION: 'FINAPP_SESSION',
};
const DARK_THEME = {
mode: 'dark',
background: '#020617',
card: '#020617',
cardAlt: '#0B1220',
accent: '#22C55E',
accentSoft: 'rgba(34,197,94,0.22)',
text: '#F9FAFB',
textMuted: '#9CA3AF',
border: '#1F2937',
danger: '#EF4444',
success: '#22C55E',
warning: '#EAB308',
};
const LIGHT_THEME = {
mode: 'light',
background: '#F3F4F6',
card: '#FFFFFF',
cardAlt: '#E5E7EB',
accent: '#0F766E',
accentSoft: 'rgba(15,118,110,0.16)',
text: '#111827',
textMuted: '#6B7280',
border: '#D1D5DB',
danger: '#DC2626',
success: '#16A34A',
warning: '#CA8A04',
};
const DEFAULT_CURRENCY = '₸';
const DEFAULT_CATEGORIES = [
'Еда',
'Транспорт',
'Жильё',
'Здоровье',
'Подписки',
'Игры/развлечения',
'Покупки',
'Сбережения',
'Другое',
];
// ---------- стили ----------
const base = StyleSheet.create({
flex1: { flex: 1 },
center: { justifyContent: 'center', alignItems: 'center' },
container: { flex: 1 },
authContainer: { flex: 1, paddingHorizontal: 24, paddingTop: 32, paddingBottom: 24 },
title: { fontSize: 28, fontWeight: '700', marginBottom: 4 },
subtitle: { fontSize: 14, marginBottom: 24 },
input: {
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 14,
marginBottom: 12,
},
button: {
borderRadius: 18,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
marginTop: 4,
},
buttonText: { fontSize: 15, fontWeight: '600' },
link: { fontSize: 14, marginTop: 12, textAlign: 'center' },
errorText: { fontSize: 13, marginBottom: 8 },
header: {
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerTitle: { fontSize: 22, fontWeight: '700' },
headerSubtitle: { fontSize: 12 },
chipRow: { flexDirection: 'row', alignItems: 'center' },
chip: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
marginLeft: 8,
},
chipText: { fontSize: 12 },
content: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 4,
},
card: {
borderRadius: 20,
padding: 16,
marginBottom: 14,
shadowColor: '#000',
shadowOpacity: 0.14,
shadowRadius: 12,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
cardHeaderRow: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 6,
},
cardTitle: { fontSize: 16, fontWeight: '600' },
cardSubtitle: { fontSize: 12 },
row: { flexDirection: 'row', alignItems: 'center' },
rowBetween: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
pill: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
marginLeft: 6,
},
pillText: { fontSize: 11, fontWeight: '500' },
tabBar: {
flexDirection: 'row',
paddingHorizontal: 6,
paddingVertical: 8,
borderTopWidth: StyleSheet.hairlineWidth,
},
tabButton: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
borderRadius: 999,
marginHorizontal: 4,
},
tabLabel: { fontSize: 11, marginTop: 2 },
badgeDot: {
width: 8,
height: 8,
borderRadius: 999,
marginRight: 4,
},
smallText: { fontSize: 12 },
tinyText: { fontSize: 11 },
sectionTitle: { fontSize: 14, fontWeight: '600', marginBottom: 8 },
inputRow: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 14,
paddingHorizontal: 10,
marginBottom: 8,
},
inputInline: { flex: 1, paddingVertical: 6, fontSize: 13 },
expenseItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 10,
},
divider: {
height: StyleSheet.hairlineWidth,
marginVertical: 6,
},
tag: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
marginRight: 6,
marginTop: 4,
},
tagText: { fontSize: 11 },
chartRow: { marginTop: 6 },
chartBarBg: {
height: 10,
borderRadius: 999,
overflow: 'hidden',
},
chartBarFill: {
height: 10,
borderRadius: 999,
},
chatBubble: {
padding: 10,
borderRadius: 14,
marginVertical: 4,
maxWidth: '85%',
},
chatUser: { alignSelf: 'flex-end' },
chatAi: { alignSelf: 'flex-start' },
badge: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 999,
marginRight: 6,
marginBottom: 6,
},
avatarCircle: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
avatarImage: {
width: 44,
height: 44,
borderRadius: 22,
},
goalImage: {
width: '100%',
height: 120,
borderRadius: 12,
marginTop: 6,
},
});
// ---------- helpers ----------
const formatCurrency = (amount, currency = DEFAULT_CURRENCY) => {
const n = Number.isFinite(amount) ? amount : 0;
const rounded = Math.round(n);
return ${currency} ${rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')};
};
const formatDate = (isoString) => {
if (!isoString) return '';
const d = new Date(isoString);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('ru-RU', {
day: '2-digit',
month: 'short',
});
};
const getMonthKey = (date) =>
${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')};
const guessCategory = (title) => {
if (!title) return 'Другое';
const t = title.toLowerCase();
if (/(super|market|магазин|магнит|кошелек|grocery|food|еда|dodo|kfc|burger)/.test(t)) return 'Еда';
if (/(аптека|pharm|apteka|drug)/.test(t)) return 'Здоровье';
if (/(taxi|такси|yandex go|bolt|uber|bus|metro)/.test(t)) return 'Транспорт';
if (/(аренда|rent|ipotek|ипотека|квартира)/.test(t)) return 'Жильё';
if (/(netflix|spotify|youtube|yandex plus|подписка|subscription)/.test(t)) return 'Подписки';
if (/(steam|psn|xbox|game|игр)/.test(t)) return 'Игры/развлечения';
if (/(cafe|кафе|coffee|кофе|restaurant|ресторан)/.test(t)) return 'Еда';
if (/(ozon|wildberries|wb|aliexpress)/.test(t)) return 'Покупки';
return 'Другое';
};
const sumBy = (arr, fn) => arr.reduce((acc, item, idx) => acc + (fn(item, idx) || 0), 0);
const buildMonthlyTotals = (expenses) => {
const totals = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
const key = getMonthKey(d);
totals[key] = (totals[key] || 0) + (Number(e.amount) || 0);
});
return totals;
};
const buildCategoryTotalsForMonth = (expenses, monthKey) => {
const m = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
if (getMonthKey(d) !== monthKey) return;
const cat = e.category || 'Другое';
m[cat] = (m[cat] || 0) + (Number(e.amount) || 0);
});
return m;
};
const forecastNextMonth = (expenses) => {
const totalsMap = buildMonthlyTotals(expenses);
const keys = Object.keys(totalsMap).sort();
if (keys.length === 0) return 0;
const ys = keys.map((k) => totalsMap[k]);
if (keys.length === 1) return ys[0];
const n = ys.length;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumX2 = 0;
for (let i = 0; i < n; i += 1) {
const x = i;
const y = ys[i];
sumX += x;
sumY += y;
sumXY += x * y;
sumX2 += x * x;
}
const denom = n * sumX2 - sumX * sumX;
const slope = denom === 0 ? 0 : (n * sumXY - sumX * sumY) / denom;
const intercept = (sumY - slope * sumX) / n;
const forecast = intercept + slope * n;
return forecast > 0 ? forecast : ys[ys.length - 1];
};
const detectAnomalies = (expenses) => {
if (!expenses.length) return [];
const amounts = expenses.map((e) => Number(e.amount) || 0);
const avg = sumBy(amounts, (a) => a) / amounts.length;
const threshold = Math.max(avg * 2, 0);
return expenses.filter((e) => (Number(e.amount) || 0) > threshold && threshold > 0);
};
const calculateDailyStreak = (expenses, monthlyIncome) => {
if (!expenses.length || !monthlyIncome) return 0;
const dailyBudget = monthlyIncome / 30;
if (dailyBudget <= 0) return 0;
const byDay = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
const key = d.toISOString().substring(0, 10);
byDay[key] = (byDay[key] || 0) + (Number(e.amount) || 0);
});
const today = new Date();
let streak = 0;
for (let i = 0; i < 365; i += 1) {
const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000);
const key = d.toISOString().substring(0, 10);
const spent = byDay[key] || 0;
if (spent <= dailyBudget) {
streak += 1;
} else {
break;
}
}
return streak;
};
const countNoSpendDaysThisMonth = (expenses) => {
if (!expenses.length) return 0;
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const spentDays = new Set();
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
if (d.getFullYear() !== year || d.getMonth() !== month) return;
spentDays.add(d.getDate());
});
return daysInMonth - spentDays.size;
};
const calculateFinancialHealth = (userData) => {
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const monthlyIncome = Number(settings.monthlyIncome) || Number(userData.income) || 0;
const now = new Date();
const currentMonthKey = getMonthKey(now);
const monthTotals = buildMonthlyTotals(expenses);
const totalThisMonth = monthTotals[currentMonthKey] || 0;
const savings = Math.max(0, monthlyIncome - totalThisMonth);
const savingsRate = monthlyIncome > 0 ? savings / monthlyIncome : 0;
const unnecessary = sumBy(expenses, (e) => {
if (e.type === 'income') return 0;
const d = new Date(e.date || new Date());
if (getMonthKey(d) !== currentMonthKey) return 0;
const c = (e.category || '').toLowerCase();
if (
c.includes('игр') ||
c.includes('развлеч') ||
c.includes('подпис') ||
c.includes('покуп') ||
c.includes('кафе') ||
c.includes('bar') ||
c.includes('club')
) {
return Number(e.amount) || 0;
}
return 0;
});
const unnecessaryShare = monthlyIncome > 0 ? unnecessary / monthlyIncome : 0;
let score = 50;
score += Math.min(20, savingsRate * 40);
score -= Math.min(15, unnecessaryShare * 30);
if (userData.goals && userData.goals.length) score += 5;
if (expenses.length > 20) score += 5;
score = Math.max(0, Math.min(100, Math.round(score)));
let label = 'Нужно больше данных';
if (score >= 80) {
label = 'Отличное состояние';
} else if (score >= 60) {
label = 'Хорошее состояние';
} else if (score >= 40) {
label = 'Среднее, есть над чем поработать';
} else {
label = 'Слабое, нужен план';
}
return {
score,
label,
monthlyIncome,
totalThisMonth,
savings,
unnecessary,
};
};
// простое шифрование заметок по PIN (XOR + hex, не для продакшена)
const encryptNote = (plain, pin) => {
if (!plain) return '';
if (!pin) return plain;
const digits = pin.split('').map((d) => parseInt(d, 10) || 0);
if (!digits.length) return plain;
let out = '';
for (let i = 0; i < plain.length; i += 1) {
const code = plain.charCodeAt(i) ^ digits[i % digits.length];
out += code.toString(16).padStart(2, '0');
}
return out;
};
const decryptNote = (cipher, pin) => {
if (!cipher) return '';
if (!pin) return cipher;
const digits = pin.split('').map((d) => parseInt(d, 10) || 0);
if (!digits.length) return cipher;
try {
let out = '';
for (let i = 0; i < cipher.length; i += 2) {
const hex = cipher.substr(i, 2);
const code = parseInt(hex, 16) ^ digits[(i / 2) % digits.length];
out += String.fromCharCode(code);
}
return out;
} catch (e) {
return '';
}
};
const buildSmartReminders = (userData) => {
const reminders = [];
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const now = new Date();
const nowKey = getMonthKey(now);
const last7Total = sumBy(expenses, (e) => {
if (e.type === 'income') return 0;
const d = new Date(e.date || new Date());
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24);
if (diff >= 0 && diff <= 7) {
return Number(e.amount) || 0;
}
return 0;
});
const weeklyBudget = Number(settings.weeklyBudget) || 0;
if (weeklyBudget > 0 && last7Total > weeklyBudget) {
reminders.push(
За последние 7 дней вы уже вышли за недельный бюджет на ${formatCurrency( last7Total - weeklyBudget, settings.currency || DEFAULT_CURRENCY, )}.,
);
} else if (weeklyBudget > 0 && last7Total > weeklyBudget * 0.8) {
reminders.push('Вы приблизились к недельному бюджету — самое время притормозить расходы.');
}
(userData.goals || []).forEach((g) => {
const target = Number(g.targetAmount) || 0;
const saved = Number(g.savedAmount) || 0;
if (!target) return;
const d = g.deadline ? new Date(g.deadline) : null;
const left = target - saved;
if (left <= 0 || !d) return;
const daysLeft = Math.round((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (daysLeft <= 0) return;
const perDay = left / daysLeft;
if (perDay > 0) {
reminders.push(
До цели «${g.title}» осталось ${formatCurrency( left, settings.currency || DEFAULT_CURRENCY, )}. Нужно откладывать примерно ${formatCurrency( perDay, settings.currency || DEFAULT_CURRENCY, )} в день.,
);
}
});
const monthTotals = buildMonthlyTotals(expenses);
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevKey = getMonthKey(prevMonth);
const totalNow = monthTotals[nowKey] || 0;
const totalPrev = monthTotals[prevKey] || 0;
if (totalPrev > 0 && totalNow > totalPrev * 1.15) {
const diff = totalNow - totalPrev;
reminders.push(
В этом месяце ваши траты уже на ${formatCurrency( diff, settings.currency || DEFAULT_CURRENCY, )} больше, чем в прошлом.,
);
}
if (!reminders.length) {
reminders.push('Вы молодец — критичных напоминаний сейчас нет 🎉');
}
return reminders;
};
const buildInsightsSummary = (userData) => {
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const now = new Date();
const currentMonthKey = getMonthKey(now);
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevKey = getMonthKey(prevMonth);
const monthTotals = buildMonthlyTotals(expenses);
const totalNow = monthTotals[currentMonthKey] || 0;
const totalPrev = monthTotals[prevKey] || 0;
const catNow = buildCategoryTotalsForMonth(expenses, currentMonthKey);
const catPrev = buildCategoryTotalsForMonth(expenses, prevKey);
const entriesNow = Object.entries(catNow);
const topCat = entriesNow.sort((a, b) => b[1] - a[1])[0];
const currency = settings.currency || DEFAULT_CURRENCY;
const anomalies = detectAnomalies(expenses).slice(0, 3);
const lines = [];
if (totalPrev > 0 && totalNow > 0) {
const delta = totalNow - totalPrev;
const pct = (delta / totalPrev) * 100;
if (Math.abs(pct) >= 5) {
if (pct > 0) {
lines.push(
Общие расходы выросли на ${pct.toFixed(1)}% по сравнению с прошлым месяцем (${formatCurrency( totalNow, currency, )} против ${formatCurrency(totalPrev, currency)}).,
);
} else {
lines.push(
Вы тратите на ${Math.abs(pct).toFixed( 1, )}% меньше, чем в прошлом месяце — отличный прогресс 👏,
);
}
}
}
if (topCat) {
lines.push(
Больше всего вы тратите на категорию «${topCat[0]}» — ${formatCurrency( topCat[1], currency, )} в этом месяце.,
);
}
Object.keys(catNow).forEach((c) => {
const nowVal = catNow[c];
const prevVal = catPrev[c] || 0;
if (prevVal <= 0) return;
const pct = (nowVal - prevVal) / prevVal;
if (pct >= 0.25 && nowVal > 0) {
lines.push(
Категория «${c}» выросла на ${Math.round( pct * 100, )}% относительно прошлого месяца — стоит проверить, можно ли там сэкономить.,
);
}
});
if (anomalies.length) {
const txt = anomalies
.map((a) => "${a.title}" на ${formatCurrency(a.amount, currency)})
.join(', ');
lines.push(Обнаружены разовые крупные траты: ${txt}. Убедитесь, что они действительно нужны.);
}
if (!lines.length) {
lines.push('Пока недостаточно данных для глубокого анализа. Начните добавлять траты 🙂');
}
return lines;
};
const generateAiAnswer = (promptRaw, userData) => {
const prompt = promptRaw.toLowerCase();
const health = calculateFinancialHealth(userData);
const insights = buildInsightsSummary(userData);
const currency = (userData.settings && userData.settings.currency) || DEFAULT_CURRENCY;
const expenses = userData.expenses || [];
const monthlyTotals = buildMonthlyTotals(expenses);
const nowKey = getMonthKey(new Date());
const totalThisMonth = monthlyTotals[nowKey] || 0;
if (!expenses.length && !prompt.includes('регистрац')) {
return 'У меня пока нет ваших данных. Добавьте несколько трат и доходов — тогда я смогу давать персональные советы.';
}
if (prompt.includes('сократить') || prompt.includes('эконом') || prompt.includes('сэкономить')) {
const catNow = buildCategoryTotalsForMonth(expenses, nowKey);
const entries = Object.entries(catNow).sort((a, b) => b[1] - a[1]);
const top3 = entries.slice(0, 3);
const tips = top3
.map(
([c, v]) =>
• «${c}» — ${formatCurrency( v, currency, )} в месяц. Попробуйте снизить хотя бы на 10–20%.,
)
.join('\n');
return (
Сейчас ваши траты за месяц: ${formatCurrency( totalThisMonth, currency, )}. Чтобы сократить их, лучше всего начать с самых крупных категорий:\n\n${tips}\n\n +
'Дополнительно:\n' +
'• установите недельный бюджет и фиксируйте все траты;\n' +
'• избегайте спонтанных покупок — делайте «паузу 24 часа»;\n' +
'• для подписок — оставьте только то, чем пользуетесь каждую неделю.'
);
}
if (prompt.includes('как распределить') || prompt.includes('зарплат')) {
const match = promptRaw.match(/(\d[\d\s]{2,})/);
const income = match ? parseInt(match[1].replace(/\s/g, ''), 10) : health.monthlyIncome || 0;
if (!income) {
return 'Напишите, пожалуйста, размер зарплаты (например: «Как распределить зарплату 300 000?»), или задайте месячный доход в настройках. Тогда я предложу детальный план распределения.';
}
const needs = income * 0.5;
const wants = income * 0.3;
const saving = income * 0.2;
return (
Классическая модель 50/30/20 для зарплаты ${formatCurrency(income, currency)}:\n\n +
• 50% (${formatCurrency(needs, currency)}) — обязательные расходы (жильё, еда, транспорт, кредиты);\n +
• 30% (${formatCurrency(wants, currency)}) — «хотелки» и развлечения;\n +
• 20% (${formatCurrency(saving, currency)}) — сбережения и цели.\n\n +
'Вы можете адаптировать: если есть кредиты — уменьшите «хотелки» до 10–15% и направьте разницу на досрочное погашение и накопления.'
);
}
if (prompt.includes('стоит ли покупать') || prompt.includes('покупать')) {
return (
'Задайте себе три вопроса перед покупкой:\n' +
'1) Нужна ли вещь прямо сейчас или это импульс?\n' +
'2) Сколько часов вашей работы она стоит?\n' +
'3) Через месяц вы будете так же рады этой покупке?\n\n' +
'Если сумма покупки >10% от месячного дохода — хорошо бы выдержать «паузу 24 часа». ' +
'Также посмотрите, не мешает ли эта покупка вашим финансовым целям (накоплениям и т.п.).'
);
}
if (prompt.includes('что если') || prompt.includes('если буду')) {
return (
'Функция «Что если?» уже доступна на вкладке «Цели» — там можно смоделировать, что будет, если сократить траты на кафе или начать откладывать больше. ' +
'Если хотите, сформулируйте сценарий конкретно (например: «Что будет, если каждый месяц откладывать на 20 000 больше?»), и я прикину эффект в чате.'
);
}
const baseInsights = insights.slice(0, 2).join('\n\n');
return (
Краткий обзор вашего финансового состояния:\n +
• Индекс финансового здоровья: ${health.score}/100 (${health.label}).\n +
• Траты в этом месяце: ${formatCurrency(totalThisMonth, currency)}.\n +
• Потенциальные сбережения: ${formatCurrency(health.savings, currency)}.\n\n +
${baseInsights}\n\n +
'Задавайте мне конкретные вопросы: например, «Как сократить расходы на 20%?» или ' +
'«Помоги перераспределить бюджет с учётом цели на 800 000».'
);
};
// helper для дефолтных данных пользователя
const createDefaultUserData = (themeMode, profile = {}) => ({
income: 0,
expenses: [],
goals: [],
subscriptions: [],
settings: {
monthlyIncome: 0,
weeklyBudget: 0,
currency: DEFAULT_CURRENCY,
antiImpulseEnabled: false,
antiImpulseThreshold: 0,
themeMode: themeMode || 'dark',
},
profile,
secureNote: '',
});
// helper для выбора картинки из галереи
const pickImageFromLibrary = async () => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Нет доступа', 'Разрешите доступ к фото в настройках устройства.');
return null;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.8,
});
if (result.canceled) return null;
const asset = result.assets && result.assets[0];
if (!asset) return null;
return asset.uri;
};
// ---------- OCR + ИИ-проверка чеков ----------
// Реальный OCR на базе Google ML Kit через expo-mlkit-ocr
const runOcrOnReceiptImage = async (uri) => {
try {
console.log('runOcrOnReceiptImage: ML Kit OCR, uri:', uri);
text// Библиотека expo-mlkit-ocr сейчас официально поддерживает только Android if (Platform.OS !== 'android') { console.warn('expo-mlkit-ocr официально поддерживает только Android. На iOS может не работать.'); } // recognizeText(uri) -> { text: string, blocks: [...] } const result = await ExpoMlkitOcr.recognizeText(uri); if (result && typeof result.text === 'string' && result.text.trim().length > 0) { return result.text; } if (result && Array.isArray(result.blocks)) { return result.blocks .map((b) => b.text) .filter(Boolean) .join('\n'); } return '';
} catch (e) {
console.warn('runOcrOnReceiptImage OCR error:', e);
return '';
}
};
// «Умный» парсер текста чека: пытается понять именно итоговую сумму
const parseReceiptOcrText = (text) => {
if (!text) return null;
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) return null;
// Денежный паттерн: 1 234,56 / 1234.56 / 12 345 / 12345 и т.п.
const MONEY_REGEX =
/\d{1,3}(?:[ \u00A0]\d{3})*(?:[.,]\d{1,2})?|\d+[.,]\d{1,2}/g;
const parseMoney = (raw) => {
if (!raw) return NaN;
let cleaned = String(raw)
.replace(/\u00A0/g, ' ')
.replace(/[^\d.,\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
textif (!cleaned) return NaN; cleaned = cleaned.replace(/\s/g, ''); if (cleaned.includes(',') && cleaned.includes('.')) { const lastComma = cleaned.lastIndexOf(','); const lastDot = cleaned.lastIndexOf('.'); if (lastComma > lastDot) { cleaned = cleaned.replace(/\./g, '').replace(',', '.'); } else { cleaned = cleaned.replace(/,/g, ''); } } else if (cleaned.includes(',')) { cleaned = cleaned.replace(',', '.'); } const value = parseFloat(cleaned); return Number.isFinite(value) ? value : NaN;
};
const isTotalLine = (line) => {
const lower = line.toLowerCase().replace(/\s+/g, ' ');
return (
/итог(о)?/.test(lower) ||
/к оплате/.test(lower) ||
/сумма к оплате/.test(lower) ||
/итог по чеку/.test(lower) ||
/total/.test(lower)
);
};
let amount = null;
// 1) Ищем строку с ИТОГ / К ОПЛАТЕ и берём сумму оттуда
for (const line of lines) {
if (!isTotalLine(line)) continue;
textconst matches = line.match(MONEY_REGEX); if (matches && matches.length) { const raw = matches[matches.length - 1]; const v = parseMoney(raw); if (Number.isFinite(v) && v > 0) { amount = v; break; } }
}
// 2) Если не нашли — смотрим последние 3–5 строк
if (amount == null) {
const tail = lines.slice(-5);
const candidates = [];
texttail.forEach((line) => { const matches = line.match(MONEY_REGEX); if (!matches) return; matches.forEach((m) => { const v = parseMoney(m); if (Number.isFinite(v) && v > 0 && v < 1e7) { candidates.push(v); } }); }); if (candidates.length) { amount = Math.max(...candidates); }
}
// 3) Совсем fallback — ищем по всему тексту
if (amount == null) {
const candidates = [];
lines.forEach((line) => {
const matches = line.match(MONEY_REGEX);
if (!matches) return;
matches.forEach((m) => {
const v = parseMoney(m);
if (Number.isFinite(v) && v > 0 && v < 1e7) {
candidates.push(v);
}
});
});
textif (candidates.length) { amount = Math.max(...candidates); }
}
// 4) Название магазина / покупки
let titleLine = '';
for (const line of lines) {
const letters = line.replace(/[^A-Za-zА-Яа-яЁё]/g, '');
if (!letters.length) continue;
textif (isTotalLine(line)) continue; if (/кассир|смена|чек|фискальн|ккм|рнк|зн/i.test(line)) continue; titleLine = line; break;
}
if (!titleLine) {
titleLine = 'Покупка по чеку';
}
const category = guessCategory(titleLine);
return {
title: titleLine,
amount: Number.isFinite(amount) && amount > 0 ? amount : 0,
category,
};
};
// ---------- небольшие компоненты ----------
const CategoryBarChart = ({ data, theme }) => {
if (!data || !data.length) return null;
const max = Math.max(...data.map((d) => d.value || 0));
if (!max) return null;
return (
<View style={base.chartRow}>
{data.map((item) => {
const pct = (item.value / max) * 100;
return (
<View key={item.label} style={{ marginBottom: 6 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>{item.label}</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{formatCurrency(item.value)}
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 3 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: theme.accent,
width: ${pct}%,
},
]}
/>
</View>
</View>
);
})}
</View>
);
};
const SimpleMonthlyChart = ({ expenses, theme }) => {
const totalsMap = buildMonthlyTotals(expenses || []);
const keys = Object.keys(totalsMap).sort();
if (!keys.length) return null;
const items = keys.slice(-6).map((k) => ({ key: k, value: totalsMap[k] }));
const max = Math.max(...items.map((i) => i.value));
if (!max) return null;
return (
<View style={{ marginTop: 4 }}>
{items.map((item) => {
const [year, month] = item.key.split('-');
const label = ${month}.${year.slice(-2)};
const pct = (item.value / max) * 100;
return (
<View key={item.key} style={{ marginBottom: 4 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>{label}</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{formatCurrency(item.value)}
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 3 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: theme.accentSoft,
width: ${pct}%,
},
]}
/>
</View>
</View>
);
})}
</View>
);
};
const SpendingGauge = ({ monthlyIncome, spent, theme, currency }) => {
if (!monthlyIncome) return null;
const ratio = monthlyIncome > 0 ? spent / monthlyIncome : 0;
const pct = Math.max(0, Math.min(1, ratio));
const labelPct = (pct * 100).toFixed(0);
return (
<View style={{ marginTop: 10 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>Расходы от дохода</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{labelPct}% ({formatCurrency(spent, currency)})
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 4, height: 12 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: ratio > 1 ? theme.danger : theme.accent,
width: ${pct * 100}%,
},
]}
/>
</View>
{ratio > 1 && (
<Text style={[base.tinyText, { color: theme.danger, marginTop: 4 }]}>
Вы вышли за рамки месячного дохода — стоит пересмотреть план.
</Text>
)}
</View>
);
};
// ---------- экраны ----------
const AuthScreen = ({ users, onLogin, onRegister, theme }) => {
const [mode, setMode] = useState('login'); // 'login' | 'register'
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [pin, setPin] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [phone, setPhone] = useState('');
const [birthDate, setBirthDate] = useState('');
const [avatarUri, setAvatarUri] = useState('');
const [error, setError] = useState('');
const toggleMode = () => {
setError('');
setMode((m) => (m === 'login' ? 'register' : 'login'));
};
const handleLoginPress = () => {
setError('');
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail || !password) {
setError('Заполните email и пароль.');
return;
}
const user = users.find((u) => u.email === normalizedEmail);
if (!user || user.password !== password) {
setError('Неверный email или пароль.');
return;
}
onLogin(user);
};
const handleRegisterPress = () => {
setError('');
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail || !password) {
setError('Заполните email и пароль.');
return;
}
if (!normalizedEmail.includes('@')) {
setError('Некорректный email.');
return;
}
if (password.length < 4) {
setError('Пароль должен быть не короче 4 символов.');
return;
}
if (!firstName.trim()) {
setError('Укажите имя.');
return;
}
if (!phone.trim()) {
setError('Укажите номер телефона.');
return;
}
if (users.some((u) => u.email === normalizedEmail)) {
setError('Пользователь с таким email уже существует.');
return;
}
const newUser = {
id: u_${Date.now()},
email: normalizedEmail,
password,
pin: pin || '',
createdAt: new Date().toISOString(),
};
const profile = {
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim(),
birthDate: birthDate.trim(),
avatarUri: avatarUri || '',
};
onRegister(newUser, profile);
};
const handlePickAvatar = async () => {
const uri = await pickImageFromLibrary();
if (uri) {
setAvatarUri(uri);
}
};
return (
<SafeAreaView
style={[
base.container,
{ backgroundColor: theme.background },
]}
>
<StatusBar
barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'}
/>
<KeyboardAvoidingView
style={base.flex1}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={[base.authContainer]}>
<View style={{ marginBottom: 32 }}>
<Text style={[base.title, { color: theme.text }]}>
Финансовый ИИ-помощник
</Text>
<Text style={[base.subtitle, { color: theme.textMuted }]}>
Управляй доходами, расходами, целями и сканируй чеки с ИИ.
</Text>
</View>
text<View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[ base.cardTitle, { color: theme.text }, ]} > {mode === 'login' ? 'Вход' : 'Регистрация'} </Text> <View style={[ base.pill, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.pillText, { color: theme.accent }, ]} > ⚡ ИИ + чек-сканер </Text> </View> </View> {error ? ( <Text style={[base.errorText, { color: theme.danger }]}>{error}</Text> ) : null} {mode === 'register' && ( <> <View style={base.row}> <TouchableOpacity onPress={handlePickAvatar}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> + </Text> </View> )} </TouchableOpacity> <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото профиля (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте аватар, чтобы приложение стало более личным. </Text> </View> </View> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} keyboardType="phone-pad" value={phone} onChangeText={setPhone} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </> )} <TextInput placeholder="Email" placeholderTextColor={theme.textMuted} autoCapitalize="none" keyboardType="email-address" value={email} onChangeText={setEmail} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Пароль" placeholderTextColor={theme.textMuted} secureTextEntry value={password} onChangeText={setPassword} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> {mode === 'register' && ( <TextInput placeholder="PIN (необязательно, для шифрования заметок)" placeholderTextColor={theme.textMuted} value={pin} onChangeText={setPin} keyboardType="number-pad" maxLength={6} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> )} <TouchableOpacity onPress={mode === 'login' ? handleLoginPress : handleRegisterPress} style={[ base.button, { backgroundColor: theme.accent, marginTop: 8 }, ]} > <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {mode === 'login' ? 'Войти' : 'Зарегистрироваться'} </Text> </TouchableOpacity> <TouchableOpacity onPress={toggleMode}> <Text style={[ base.link, { color: theme.textMuted }, ]} > {mode === 'login' ? 'Нет аккаунта? Создать' : 'Уже есть аккаунт? Войти'} </Text> </TouchableOpacity> </View> <View style={{ marginTop: 24 }}> <Text style={[ base.smallText, { color: theme.textMuted }, ]} > Данные хранятся только локально на устройстве (AsyncStorage). Для реального приложения стоит добавить серверный бэкап и более серьёзную криптографию. </Text> </View> </View> </KeyboardAvoidingView> </SafeAreaView>
);
};
const HomeScreen = ({ userData, theme }) => {
const health = calculateFinancialHealth(userData);
const insights = buildInsightsSummary(userData);
const reminders = buildSmartReminders(userData);
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const now = new Date();
const currentMonthKey = getMonthKey(now);
const catNowMap = buildCategoryTotalsForMonth(expenses, currentMonthKey);
const categoryData = Object.entries(catNowMap)
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value);
const forecast = forecastNextMonth(expenses);
const streak = calculateDailyStreak(
expenses,
Number(settings.monthlyIncome) || Number(userData.income) || 0,
);
const noSpendDays = countNoSpendDaysThisMonth(expenses);
const profile = userData.profile || {};
const firstName = profile.firstName || '';
const badges = [];
if (health.score >= 80) badges.push('Экономист месяца');
if (streak >= 7) badges.push('7 дней в плюсе');
if (noSpendDays >= 5) badges.push('Дни без трат');
if (expenses.length >= 50) badges.push('Финансовый самурай');
return (
<ScrollView
style={[
base.content,
{ backgroundColor: theme.background },
]}
showsVerticalScrollIndicator={false}
>
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.rowBetween}>
<View style={{ flex: 1 }}>
<Text style={[base.cardSubtitle, { color: theme.textMuted }]}>
Индекс финансового здоровья
</Text>
<Text
style={[
{ fontSize: 32, fontWeight: '700', marginTop: 4 },
{ color: theme.text },
]}
>
{health.score}
<Text style={{ fontSize: 16 }}>/100</Text>
</Text>
{firstName ? (
<Text
style={[
base.tinyText,
{ color: theme.textMuted, marginTop: 4 },
]}
>
{firstName}, это ваш персональный индекс.
</Text>
) : null}
</View>
<View
style={[
{
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
},
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.tinyText, { color: theme.textMuted }]}>Статус</Text>
<Text
style={[
base.smallText,
{ color: theme.text, fontWeight: '600', marginTop: 2 },
]}
>
{health.label}
</Text>
</View>
</View>
text<View style={{ marginTop: 10 }}> <View style={base.rowBetween}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Доход / мес.</Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {health.monthlyIncome ? formatCurrency(health.monthlyIncome, currency) : 'Не задан'} </Text> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Траты в этом месяце </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(health.totalThisMonth, currency)} </Text> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Сбережения</Text> <Text style={[ base.smallText, { color: health.savings >= 0 ? theme.success : theme.danger, marginTop: 2, }, ]} > {formatCurrency(health.savings, currency)} </Text> </View> <SpendingGauge monthlyIncome={health.monthlyIncome} spent={health.totalThisMonth} theme={theme} currency={currency} /> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Привычки</Text> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Серия дней в рамках бюджета: {streak} </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Дней без трат в этом месяце: {noSpendDays} </Text> </View> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Бейджи</Text> <View style={[base.row, { flexWrap: 'wrap', marginTop: 4 }]}> {badges.length ? ( badges.map((b) => ( <View key={b} style={[ base.badge, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.tinyText, { color: theme.accent }, ]} > ✨ {b} </Text> </View> )) ) : ( <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Собирайте награды, держась в рамках бюджета. </Text> )} </View> </View> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>AI-инсайты</Text> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>🧩 Анализ</Text> </View> </View> {insights.slice(0, 3).map((line, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{line}</Text> </View> ))} </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Категории</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Текущий месяц </Text> </View> <CategoryBarChart data={categoryData} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Динамика</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Прогноз: {formatCurrency(forecast, currency)} </Text> </View> <SimpleMonthlyChart expenses={expenses} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Умные напоминания</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔔</Text> </View> {reminders.map((r, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{r}</Text> </View> ))} </View> </ScrollView>
);
};
const ExpensesScreen = ({ userData, onUpdateUserData, theme }) => {
const [title, setTitle] = useState('');
const [amount, setAmount] = useState('');
const [category, setCategory] = useState('');
const [isIncome, setIsIncome] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [lastReceiptPreview, setLastReceiptPreview] = useState(null); // { uri, text }
const expenses = [...(userData.expenses || [])].sort(
(a, b) => new Date(b.date || 0).getTime() - new Date(a.date || 0).getTime(),
);
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const antiImpulseEnabled = settings.antiImpulseEnabled;
const antiImpulseThreshold = Number(settings.antiImpulseThreshold) || 0;
const addEntry = (n, guessed, customTitle, extraMeta) => {
const entry = {
id: exp_${Date.now()},
title: (customTitle || title).trim(),
amount: n,
category: guessed,
type: isIncome ? 'income' : 'expense',
date: new Date().toISOString(),
...(extraMeta ? { meta: extraMeta } : {}),
};
onUpdateUserData((prev) => ({
...prev,
expenses: [...(prev.expenses || []), entry],
}));
setTitle('');
setAmount('');
setCategory('');
setIsIncome(false);
};
const handleAdd = () => {
const n = parseFloat(String(amount).replace(',', '.'));
if (!title || !Number.isFinite(n) || n <= 0) {
Alert.alert('Ошибка', 'Введите название и сумму.');
return;
}
const guessed = category || guessCategory(title);
textif (!isIncome && antiImpulseEnabled && antiImpulseThreshold > 0 && n >= antiImpulseThreshold) { Alert.alert( 'Анти-импульсная защита', `Покупка на ${formatCurrency( n, currency, )} превышает ваш лимит в ${formatCurrency(antiImpulseThreshold, currency)}.\n\n` + 'Вы уверены, что хотите продолжить?', [ { text: 'Отменить', style: 'cancel' }, { text: 'Купить', style: 'destructive', onPress: () => addEntry(n, guessed), }, ], ); return; } addEntry(n, guessed);
};
const handleScanReceipt = async () => {
try {
setIsScanning(true);
const perm = await ImagePicker.requestCameraPermissionsAsync();
if (!perm.granted) {
Alert.alert('Нет доступа', 'Разрешите доступ к камере в настройках устройства.');
return;
}
textconst result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8, }); if (result.canceled) return; const asset = result.assets && result.assets[0]; if (!asset) return; const uri = asset.uri; const ocrText = await runOcrOnReceiptImage(uri); setLastReceiptPreview({ uri, text: ocrText }); const parsed = parseReceiptOcrText(ocrText); if (!parsed || !parsed.amount || parsed.amount <= 0) { Alert.alert( 'Не удалось распознать чек', 'Попробуйте сделать фото поярче и ровнее, чтобы мы могли прочитать сумму.', ); return; } const guessedCategory = parsed.category || guessCategory(parsed.title); const n = parsed.amount; // Авто-добавление в расходы onUpdateUserData((prev) => ({ ...prev, expenses: [ ...(prev.expenses || []), { id: `exp_receipt_${Date.now()}`, title: parsed.title || 'Покупка по чеку', amount: n, category: guessedCategory, type: 'expense', date: new Date().toISOString(), meta: { source: 'receipt', ocrText, imageUri: uri, }, }, ], })); Alert.alert( 'Чек добавлен', `Мы распознали сумму ${formatCurrency( n, currency, )} и категорию «${guessedCategory}».\nЗапись добавлена в историю расходов.`, ); } catch (e) { console.warn('Receipt scan error', e); Alert.alert('Ошибка', 'Не удалось обработать чек. Попробуйте ещё раз.'); } finally { setIsScanning(false); }
};
const totalExpenses = sumBy(expenses, (e) =>
e.type === 'income' ? 0 : Number(e.amount) || 0,
);
const totalIncome = sumBy(expenses, (e) =>
e.type === 'income' ? Number(e.amount) || 0 : 0,
);
return (
<View
style={[
base.content,
{ backgroundColor: theme.background },
]}
>
<ScrollView
style={{ flex: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* ИИ-проверка чеков */}
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<View style={{ flex: 1, paddingRight: 6 }}>
<Text style={[base.cardTitle, { color: theme.text }]}>
ИИ-проверка чеков
</Text>
<Text style={[base.tinyText, { color: theme.textMuted, marginTop: 2 }]}>
Сделайте фото чека — мы распознаем сумму и автоматически добавим расход.
</Text>
</View>
<View style={[base.pill, { backgroundColor: theme.accentSoft }]}>
<Text style={[base.pillText, { color: theme.accent }]}>🤖 Чек-сканер</Text>
</View>
</View>
text<TouchableOpacity onPress={handleScanReceipt} disabled={isScanning} style={[ base.button, { backgroundColor: isScanning ? theme.cardAlt : theme.accent, marginTop: 8, flexDirection: 'row', }, ]} > {isScanning && ( <ActivityIndicator size="small" style={{ marginRight: 8 }} color="#FFFFFF" /> )} <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {isScanning ? 'Сканируем чек...' : 'Сканировать чек с ИИ'} </Text> </TouchableOpacity> {lastReceiptPreview && ( <View style={[base.row, { marginTop: 10 }]}> <Image source={{ uri: lastReceiptPreview.uri }} style={{ width: 56, height: 56, borderRadius: 12, marginRight: 8 }} /> <View style={{ flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Последний чек (OCR) </Text> <Text numberOfLines={3} style={[base.smallText, { color: theme.text, marginTop: 2 }]} > {lastReceiptPreview.text || 'Текст не распознан'} </Text> </View> </View> )} </View> {/* Ручное добавление записи */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Новая запись</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}> Доход </Text> <Switch value={isIncome} onValueChange={setIsIncome} thumbColor={isIncome ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Магазин / описание" placeholderTextColor={theme.textMuted} value={title} onChangeText={(text) => { setTitle(text); if (!category) { setCategory(guessCategory(text)); } }} style={[base.inputInline, { color: theme.text }]} /> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} keyboardType="numeric" value={amount} onChangeText={setAmount} style={[base.inputInline, { color: theme.text }]} /> <Text style={[base.smallText, { color: theme.textMuted }]}>{currency}</Text> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Категория</Text> <ScrollView horizontal showsHorizontalScrollIndicator={false}> {DEFAULT_CATEGORIES.map((c) => ( <TouchableOpacity key={c} onPress={() => setCategory(c)} style={[ base.tag, { backgroundColor: category === c ? theme.accentSoft : theme.cardAlt, borderWidth: 1, borderColor: category === c ? theme.accent : theme.border, }, ]} > <Text style={[ base.tagText, { color: category === c ? theme.accent : theme.textMuted, }, ]} > {c} </Text> </TouchableOpacity> ))} </ScrollView> </View> <TouchableOpacity onPress={handleAdd} style={[ base.button, { backgroundColor: theme.accent, marginTop: 12 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить </Text> </TouchableOpacity> </View> {/* Итоги */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.rowBetween}> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего расходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalExpenses, currency)} </Text> </View> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего доходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalIncome, currency)} </Text> </View> </View> </View> {/* История */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>История</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> {expenses.length} записей </Text> </View> {expenses.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Добавьте первую трату или доход. </Text> ) : ( <FlatList data={expenses} keyExtractor={(item) => item.id} scrollEnabled={false} renderItem={({ item }) => { const isImpulseHighlight = item.type !== 'income' && antiImpulseEnabled && antiImpulseThreshold > 0 && Number(item.amount) >= antiImpulseThreshold; const isReceipt = item.meta && item.meta.source === 'receipt'; return ( <View> <View style={base.expenseItem}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> {item.title} </Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted }]}> {formatDate(item.date)} </Text> <View style={[ base.tag, { backgroundColor: theme.cardAlt, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.textMuted }, ]} > {item.category || 'Другое'} </Text> </View> {isReceipt && ( <View style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > ИИ-чек </Text> </View> )} </View> </View> <Text style={[ base.smallText, { color: item.type === 'income' ? theme.success : isImpulseHighlight ? theme.danger : theme.text, }, ]} > {item.type === 'income' ? '+' : '-'} {formatCurrency(item.amount, currency)} </Text> </View> <View style={[ base.divider, { backgroundColor: theme.border }, ]} /> </View> ); }} /> )} </View> </ScrollView> </View>
);
};
const GoalsScreen = ({ userData, onUpdateUserData, theme }) => {
const [title, setTitle] = useState('');
const [target, setTarget] = useState('');
const [months, setMonths] = useState('');
const [whatIfCafe, setWhatIfCafe] = useState('30');
const [whatIfSave, setWhatIfSave] = useState('10000');
const [goalImage, setGoalImage] = useState(null);
const [editGoalId, setEditGoalId] = useState(null);
const [editTitle, setEditTitle] = useState('');
const [editTarget, setEditTarget] = useState('');
const [editDeadlineMonths, setEditDeadlineMonths] = useState('');
const goals = userData.goals || [];
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const handleAddGoal = () => {
const t = parseFloat(String(target).replace(',', '.'));
const m = parseInt(months, 10);
if (!title || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) {
Alert.alert('Ошибка', 'Укажите цель, сумму и срок в месяцах.');
return;
}
const deadline = new Date();
deadline.setMonth(deadline.getMonth() + m);
const newGoal = {
id: goal_${Date.now()},
title: title.trim(),
targetAmount: t,
savedAmount: 0,
deadline: deadline.toISOString(),
imageUri: goalImage || null,
};
onUpdateUserData((prev) => ({
...prev,
goals: [...(prev.goals || []), newGoal],
}));
setTitle('');
setTarget('');
setMonths('');
setGoalImage(null);
};
const handleDeposit = (goalId, amount) => {
const n = amount || 0;
onUpdateUserData((prev) => ({
...prev,
goals: (prev.goals || []).map((g) =>
g.id === goalId
? { ...g, savedAmount: (Number(g.savedAmount) || 0) + n }
: g,
),
expenses: [
...(prev.expenses || []),
{
id: save_${Date.now()},
title: 'Перевод в копилку',
amount: n,
category: 'Сбережения',
type: 'expense',
date: new Date().toISOString(),
},
],
}));
};
const handlePickGoalImage = async () => {
const uri = await pickImageFromLibrary();
if (uri) setGoalImage(uri);
};
const handleEditGoalPress = (goal) => {
const now = new Date();
const deadline = goal.deadline ? new Date(goal.deadline) : null;
const monthsLeft =
deadline && !Number.isNaN(deadline.getTime())
? Math.max(
1,
Math.round(
(deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30),
),
)
: '';
textsetEditGoalId(goal.id); setEditTitle(goal.title); setEditTarget(String(goal.targetAmount || '')); setEditDeadlineMonths(monthsLeft ? String(monthsLeft) : '');
};
const handleSaveEditedGoal = () => {
if (!editGoalId) return;
const t = parseFloat(String(editTarget).replace(',', '.'));
const m = parseInt(editDeadlineMonths, 10);
if (!editTitle || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) {
Alert.alert('Ошибка', 'Укажите корректные данные для цели.');
return;
}
const newDeadline = new Date();
newDeadline.setMonth(newDeadline.getMonth() + m);
textonUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).map((g) => g.id === editGoalId ? { ...g, title: editTitle.trim(), targetAmount: t, deadline: newDeadline.toISOString(), } : g, ), })); setEditGoalId(null); setEditTitle(''); setEditTarget(''); setEditDeadlineMonths('');
};
const handleCancelEdit = () => {
setEditGoalId(null);
setEditTitle('');
setEditTarget('');
setEditDeadlineMonths('');
};
const handleDeleteGoal = (goalId) => {
Alert.alert(
'Удалить цель',
'Вы уверены, что хотите удалить эту цель? Прогресс по ней будет потерян.',
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Удалить',
style: 'destructive',
onPress: () => {
onUpdateUserData((prev) => ({
...prev,
goals: (prev.goals || []).filter((g) => g.id !== goalId),
}));
},
},
],
);
};
const now = new Date();
const totalSavedAll = sumBy(goals, (g) => Number(g.savedAmount) || 0);
const totalTargetAll = sumBy(goals, (g) => Number(g.targetAmount) || 0);
const cafeReduce = parseFloat(String(whatIfCafe).replace(',', '.')) || 0;
const extraWeekly = parseFloat(String(whatIfSave).replace(',', '.')) || 0;
const cafeCurrent = sumBy(userData.expenses || [], (e) => {
if (e.type === 'income') return 0;
const c = (e.category || '').toLowerCase();
if (c.includes('еда') || c.includes('кафе')) {
const d = new Date(e.date || new Date());
const key = getMonthKey(d);
if (key === getMonthKey(now)) {
return Number(e.amount) || 0;
}
}
return 0;
});
const cafePotential = cafeCurrent * (cafeReduce / 100);
const whatIfMonthlyExtra = cafePotential + extraWeekly * 4;
return (
<ScrollView
style={[
base.content,
{ backgroundColor: theme.background },
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Блок создания цели */}
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<Text style={[base.cardTitle, { color: theme.text }]}>Цели и копилки</Text>
<View style={[base.pill, { backgroundColor: theme.accentSoft }]}>
<Text style={[base.pillText, { color: theme.accent }]}>💎 Планы</Text>
</View>
</View>
<Text style={[base.tinyText, { color: theme.textMuted }]}>
Всего накоплено: {formatCurrency(totalSavedAll, currency)} из{' '}
{formatCurrency(totalTargetAll || 0, currency)}
</Text>
text<View style={{ marginTop: 12 }}> <TouchableOpacity onPress={handlePickGoalImage} style={[ base.row, { marginBottom: 8, }, ]} > {goalImage ? ( <Image source={{ uri: goalImage }} style={[base.goalImage, { width: 80, height: 80 }]} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}>📷</Text> </View> )} <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото цели (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте визуал — так легче держаться плана. </Text> </View> </TouchableOpacity> <TextInput placeholder="Название цели (например, «Ноутбук»)" placeholderTextColor={theme.textMuted} value={title} onChangeText={setTitle} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={target} onChangeText={setTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={months} onChangeText={setMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <TouchableOpacity onPress={handleAddGoal} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить цель </Text> </TouchableOpacity> </View> </View> {/* Список целей с редактированием и удалением */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Мои цели</Text> </View> {goals.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Создайте первую цель, чтобы следить за прогрессом. </Text> ) : ( goals.map((g) => { const targetAmount = Number(g.targetAmount) || 0; const savedAmount = Number(g.savedAmount) || 0; const pct = targetAmount ? Math.min(100, (savedAmount / targetAmount) * 100) : 0; const deadline = g.deadline ? new Date(g.deadline) : null; const daysLeft = deadline && !Number.isNaN(deadline.getTime()) ? Math.round( (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), ) : null; const left = Math.max(0, targetAmount - savedAmount); const perWeek = daysLeft && daysLeft > 0 ? (left / daysLeft) * 7 : left / 4 || 0; const isEditing = editGoalId === g.id; return ( <View key={g.id} style={{ marginBottom: 12 }}> <View style={base.rowBetween}> <Text style={[base.smallText, { color: theme.text, maxWidth: '70%' }]}> {g.title} </Text> <View style={base.row}> <TouchableOpacity onPress={() => handleEditGoalPress(g)}> <Text style={[base.tinyText, { color: theme.accent, marginRight: 12 }]}> Изм. </Text> </TouchableOpacity> <TouchableOpacity onPress={() => handleDeleteGoal(g.id)}> <Text style={[base.tinyText, { color: theme.danger }]}> Удалить </Text> </TouchableOpacity> </View> </View> {g.imageUri ? ( <Image source={{ uri: g.imageUri }} style={base.goalImage} /> ) : null} <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accent, width: `${pct}%`, }, ]} /> </View> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> {formatCurrency(savedAmount, currency)} из{' '} {formatCurrency(targetAmount, currency)} </Text> {daysLeft !== null && ( <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 4 }, ]} > Осталось ~{daysLeft} дн. </Text> )} </View> {left > 0 && ( <View style={base.rowBetween}> <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 2 }, ]} > Чтобы успеть: ~{formatCurrency(perWeek, currency)} в неделю </Text> <TouchableOpacity onPress={() => handleDeposit(g.id, 10000)} style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 2, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > +10 000 в копилку </Text> </TouchableOpacity> </View> )} {isEditing && ( <View style={{ marginTop: 8, padding: 10, borderRadius: 12, backgroundColor: theme.cardAlt, }} > <Text style={[base.tinyText, { color: theme.textMuted, marginBottom: 4 }]}> Редактирование цели </Text> <TextInput placeholder="Название цели" placeholderTextColor={theme.textMuted} value={editTitle} onChangeText={setEditTitle} style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={editTarget} onChangeText={setEditTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={editDeadlineMonths} onChangeText={setEditDeadlineMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={base.rowBetween}> <TouchableOpacity onPress={handleSaveEditedGoal} style={[ base.button, { flex: 1, backgroundColor: theme.accent, marginTop: 4, marginRight: 4, }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить </Text> </TouchableOpacity> <TouchableOpacity onPress={handleCancelEdit} style={[ base.button, { flex: 1, backgroundColor: theme.card, marginTop: 4, marginLeft: 4, borderWidth: 1, borderColor: theme.border, }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Отмена </Text> </TouchableOpacity> </View> </View> )} <View style={[ base.divider, { backgroundColor: theme.border, marginTop: 8 }, ]} /> </View> ); }) )} </View> {/* Блок «Что если?» */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>«Что если?»</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧪 Симулятор</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Смоделируйте изменения привычек и посмотрите влияние на накопления. </Text> <View style={{ marginTop: 8 }}> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> На сколько % сократить кафе/еду? </Text> <TextInput placeholder="30" placeholderTextColor={theme.textMuted} value={whatIfCafe} onChangeText={setWhatIfCafe} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Доп. отложить в неделю </Text> <TextInput placeholder="10 000" placeholderTextColor={theme.textMuted} value={whatIfSave} onChangeText={setWhatIfSave} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.smallText, { color: theme.text }]}> При таком сценарии вы сможете дополнительно накопить около{' '} {formatCurrency(whatIfMonthlyExtra, currency)} в месяц. </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> За год это будет примерно{' '} {formatCurrency(whatIfMonthlyExtra * 12, currency)} — почти целая отдельная цель 💡 </Text> </View> </View> </View> </ScrollView>
);
};
const AiChatScreen = ({ userData, theme }) => {
const [messages, setMessages] = useState([
{
id: 'm_hello',
from: 'ai',
text: 'Привет! Я твой финансовый ИИ-ассистент. Задавай вопросы: «Как сократить расходы на 20%?», «Как распределить зарплату?», «Что будет, если начать откладывать больше?»',
},
]);
const [input, setInput] = useState('');
const [voiceOn, setVoiceOn] = useState(false);
const sendText = (text) => {
const trimmed = text.trim();
if (!trimmed) return;
const userMsg = {
id: m_${Date.now()}_u,
from: 'user',
text: trimmed,
};
const aiText = generateAiAnswer(trimmed, userData);
const aiMsg = {
id: m_${Date.now()}_ai,
from: 'ai',
text: aiText,
};
setMessages((prev) => [...prev, userMsg, aiMsg]);
setInput('');
if (voiceOn) {
Speech.stop();
Speech.speak(aiText, { language: 'ru-RU' });
}
};
const handleSend = () => {
sendText(input);
};
const quickPrompts = [
'Как сократить расходы на 20%?',
'Как распределить зарплату 300 000?',
'Стоит ли покупать новый телефон?',
'Сделай обзор моих трат.',
];
return (
<KeyboardAvoidingView
style={[
base.content,
{ backgroundColor: theme.background },
]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<Text style={[base.cardTitle, { color: theme.text }]}>ИИ-консультант</Text>
<View style={base.row}>
<Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}>
Голос
</Text>
<Switch
value={voiceOn}
onValueChange={setVoiceOn}
thumbColor={voiceOn ? theme.accent : '#9CA3AF'}
trackColor={{ true: theme.accentSoft, false: theme.cardAlt }}
/>
</View>
</View>
<Text style={[base.tinyText, { color: theme.textMuted }]}>
Ответы основаны на ваших реальных данных: расходах, доходах, целях.
</Text>
<View
style={{
marginTop: 8,
flexDirection: 'row',
flexWrap: 'wrap',
}}
>
{quickPrompts.map((p) => (
<TouchableOpacity
key={p}
onPress={() => sendText(p)}
style={[
base.badge,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.tinyText, { color: theme.text }]}>{p}</Text>
</TouchableOpacity>
))}
</View>
</View>
text<View style={[ base.card, { flex: 1, backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 8 }} showsVerticalScrollIndicator={false} > {messages.map((m) => { const isUser = m.from === 'user'; return ( <View key={m.id} style={[ base.chatBubble, isUser ? base.chatUser : base.chatAi, { backgroundColor: isUser ? theme.accent : theme.cardAlt, }, ]} > <Text style={[ base.tinyText, { color: isUser ? '#FFFFFF' : theme.textMuted }, ]} > {isUser ? 'Вы' : 'ИИ'} </Text> <Text style={[ base.smallText, { color: isUser ? '#FFFFFF' : theme.text, marginTop: 2, }, ]} > {m.text} </Text> </View> ); })} </ScrollView> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, marginTop: 4, }, ]} > <TextInput placeholder="Задайте вопрос по финансам..." placeholderTextColor={theme.textMuted} value={input} onChangeText={setInput} style={[base.inputInline, { color: theme.text }]} multiline /> <TouchableOpacity onPress={handleSend}> <Text style={[base.smallText, { color: theme.accent }]}>Отпр.</Text> </TouchableOpacity> </View> </View> </KeyboardAvoidingView>
);
};
const ProfileScreen = ({
currentUser,
userData,
onUpdateUserData,
theme,
onLogout,
onToggleTheme,
}) => {
const profile = userData.profile || {};
const [firstName, setFirstName] = useState(profile.firstName || '');
const [lastName, setLastName] = useState(profile.lastName || '');
const [phone, setPhone] = useState(profile.phone || '');
const [birthDate, setBirthDate] = useState(profile.birthDate || '');
const [avatarUri, setAvatarUri] = useState(profile.avatarUri || '');
const [monthlyIncome, setMonthlyIncome] = useState(
String((userData.settings && userData.settings.monthlyIncome) || ''),
);
const [weeklyBudget, setWeeklyBudget] = useState(
String((userData.settings && userData.settings.weeklyBudget) || ''),
);
const [currency, setCurrency] = useState(
(userData.settings && userData.settings.currency) || DEFAULT_CURRENCY,
);
const [antiImpulseEnabled, setAntiImpulseEnabled] = useState(
(userData.settings && userData.settings.antiImpulseEnabled) || false,
);
const [antiImpulseThreshold, setAntiImpulseThreshold] = useState(
String((userData.settings && userData.settings.antiImpulseThreshold) || ''),
);
const [pinInput, setPinInput] = useState('');
const [noteDraft, setNoteDraft] = useState('');
const [noteUnlocked, setNoteUnlocked] = useState(false);
useEffect(() => {
const savedEncrypted = userData.secureNote || '';
if (savedEncrypted && currentUser.pin) {
const dec = decryptNote(savedEncrypted, currentUser.pin);
setNoteDraft(dec);
} else if (savedEncrypted && !currentUser.pin) {
setNoteDraft(savedEncrypted);
} else {
setNoteDraft('');
}
}, [userData.secureNote, currentUser.pin]);
useEffect(() => {
const p = userData.profile || {};
setFirstName(p.firstName || '');
setLastName(p.lastName || '');
setPhone(p.phone || '');
setBirthDate(p.birthDate || '');
setAvatarUri(p.avatarUri || '');
}, [userData.profile]);
const handleSaveSettings = () => {
const m = parseFloat(String(monthlyIncome).replace(',', '.')) || 0;
const w = parseFloat(String(weeklyBudget).replace(',', '.')) || 0;
const anti =
parseFloat(String(antiImpulseThreshold).replace(',', '.')) || 0;
textonUpdateUserData((prev) => ({ ...prev, income: m, settings: { ...(prev.settings || {}), monthlyIncome: m, weeklyBudget: w, currency: currency || DEFAULT_CURRENCY, antiImpulseEnabled, antiImpulseThreshold: anti, }, profile: { ...(prev.profile || {}), firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), birthDate: birthDate.trim(), avatarUri: avatarUri || '', }, })); Alert.alert('Сохранено', 'Профиль и бюджет обновлены.');
};
const handleUnlockNote = () => {
if (!currentUser.pin) {
setNoteUnlocked(true);
return;
}
if (pinInput === currentUser.pin) {
const dec = decryptNote(userData.secureNote || '', currentUser.pin);
setNoteDraft(dec);
setNoteUnlocked(true);
} else {
Alert.alert('Ошибка', 'Неверный PIN.');
}
};
const handleSaveNote = () => {
let encrypted = noteDraft;
if (currentUser.pin) {
encrypted = encryptNote(noteDraft, currentUser.pin);
}
onUpdateUserData((prev) => ({
...prev,
secureNote: encrypted,
}));
Alert.alert('Сохранено', 'Заметка зашифрована и сохранена.');
};
const totalSubs = (userData.subscriptions || []).length;
const handlePickAvatar = async () => {
const uri = await pickImageFromLibrary();
if (uri) setAvatarUri(uri);
};
const displayName =
firstName || lastName
? ${firstName} ${lastName}.trim()
: currentUser.email;
return (
<ScrollView
style={[
base.content,
{ backgroundColor: theme.background },
]}
showsVerticalScrollIndicator={false}
>
{/* Профиль и быстрый статус */}
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<View style={base.row}>
<TouchableOpacity onPress={handlePickAvatar}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={base.avatarImage} />
) : (
<View
style={[
base.avatarCircle,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.smallText, { color: theme.textMuted }]}>
{firstName
? firstName.charAt(0).toUpperCase()
: currentUser.email.charAt(0).toUpperCase()}
</Text>
</View>
)}
</TouchableOpacity>
<View style={{ marginLeft: 10 }}>
<Text style={[base.cardTitle, { color: theme.text }]}>
{displayName || 'Пользователь'}
</Text>
<Text style={[base.tinyText, { color: theme.textMuted }]}>
{currentUser.email}
</Text>
</View>
</View>
<TouchableOpacity onPress={onLogout}>
<Text style={[base.smallText, { color: theme.danger }]}>Выйти</Text>
</TouchableOpacity>
</View>
<Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}>
Подписок учтено: {totalSubs} • PIN установлен:{' '}
{currentUser.pin ? 'да' : 'нет'}
</Text>
text<View style={{ marginTop: 12 }}> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} value={phone} onChangeText={setPhone} keyboardType="phone-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> {/* Бюджет и валюта */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Бюджет</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Установите доход, недельный бюджет и валюту — это нужно для индекса «финансовое здоровье» и рекомендаций. </Text> <TextInput placeholder="Доход в месяц" placeholderTextColor={theme.textMuted} value={monthlyIncome} onChangeText={setMonthlyIncome} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Недельный бюджет" placeholderTextColor={theme.textMuted} value={weeklyBudget} onChangeText={setWeeklyBudget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Валюта (например, ₸, ₽, $)" placeholderTextColor={theme.textMuted} value={currency} onChangeText={setCurrency} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TouchableOpacity onPress={handleSaveSettings} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить профиль и бюджет </Text> </TouchableOpacity> </View> {/* Тема */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Тема и вид</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 6 }]}> Тёмная тема </Text> <Switch value={theme.mode === 'dark'} onValueChange={onToggleTheme} thumbColor={theme.mode === 'dark' ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> В будущем сюда можно добавить иконки, виджеты и другие настройки UI. </Text> </View> {/* Анти-импульсная защита */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Контроль импульсных покупок</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧠 Эксперимент</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Включите анти-импульсный режим, чтобы приложение задавало вам «контрольный вопрос» перед крупной покупкой. </Text> <View style={[base.rowBetween, { marginTop: 8 }]}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> Анти-импульсный режим </Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Срабатывает при тратах выше порога. </Text> </View> <Switch value={antiImpulseEnabled} onValueChange={setAntiImpulseEnabled} thumbColor={antiImpulseEnabled ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> <TextInput placeholder="Порог крупной покупки (например, 50 000)" placeholderTextColor={theme.textMuted} value={antiImpulseThreshold} onChangeText={setAntiImpulseThreshold} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> </View> {/* Безопасная заметка */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Безопасная заметка</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔐 Локально</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Заметка шифруется простым алгоритмом (XOR + HEX) с вашим PIN и хранится только в AsyncStorage. Для реального проекта используйте надёжную криптографию (AES и т.п.) и биометрию. </Text> {!noteUnlocked && ( <> {currentUser.pin ? ( <> <TextInput placeholder="Введите PIN для разблокировки" placeholderTextColor={theme.textMuted} value={pinInput} onChangeText={setPinInput} keyboardType="number-pad" secureTextEntry style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleUnlockNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Разблокировать заметку </Text> </TouchableOpacity> </> ) : ( <> <Text style={[base.smallText, { color: theme.text, marginTop: 8 }]}> PIN не установлен при регистрации, поэтому заметка хранится без шифрования. </Text> <TouchableOpacity onPress={() => setNoteUnlocked(true)} style={[ base.button, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Показать заметку </Text> </TouchableOpacity> </> )} </> )} {noteUnlocked && ( <> <TextInput placeholder="Секретная финансовая заметка (пароли сюда лучше не писать)" placeholderTextColor={theme.textMuted} value={noteDraft} onChangeText={setNoteDraft} multiline style={[ base.input, { minHeight: 80, textAlignVertical: 'top', backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleSaveNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить зашифровано </Text> </TouchableOpacity> </> )} </View> </ScrollView>
);
};
const MainApp = ({
currentUser,
userData,
setUserData,
onLogout,
themeMode,
setThemeMode,
}) => {
const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME;
const [activeTab, setActiveTab] = useState('home');
const handleUpdateUserData = (updater) => {
setUserData((prev) => {
const prevVal = prev || createDefaultUserData(themeMode);
const next = typeof updater === 'function' ? updater(prevVal) : updater;
return next;
});
};
const handleToggleTheme = () => {
setThemeMode((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
const profile = userData.profile || {};
const avatarUri = profile.avatarUri;
const displayName =
profile.firstName || profile.lastName
? ${profile.firstName || ''} ${profile.lastName || ''}.trim()
: currentUser.email;
const initials =
profile.firstName || profile.lastName
? ${(profile.firstName || profile.lastName || 'U').charAt(0).toUpperCase()}
: currentUser.email.charAt(0).toUpperCase();
return (
<SafeAreaView
style={[
base.container,
{ backgroundColor: theme.background },
]}
>
<StatusBar
barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'}
/>
<View style={base.header}>
<View style={base.row}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={base.avatarImage} />
) : (
<View
style={[
base.avatarCircle,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.smallText, { color: theme.textMuted }]}>
{initials}
</Text>
</View>
)}
<View style={{ marginLeft: 10 }}>
<Text style={[base.headerTitle, { color: theme.text }]}>
Мой бюджет
</Text>
<Text style={[base.headerSubtitle, { color: theme.textMuted }]}>
{displayName}
</Text>
</View>
</View>
<View style={base.chipRow}>
<View
style={[
base.chip,
{ backgroundColor: theme.accentSoft },
]}
>
<Text style={[base.chipText, { color: theme.accent }]}>🤖 ИИ-чек</Text>
</View>
<TouchableOpacity
onPress={handleToggleTheme}
style={[
base.chip,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.chipText, { color: theme.text }]}>
{theme.mode === 'dark' ? '◐' : '◑'}
</Text>
</TouchableOpacity>
</View>
</View>
text{activeTab === 'home' && <HomeScreen userData={userData} theme={theme} />} {activeTab === 'expenses' && ( <ExpensesScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'goals' && ( <GoalsScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'ai' && <AiChatScreen userData={userData} theme={theme} />} {activeTab === 'profile' && ( <ProfileScreen currentUser={currentUser} userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} onLogout={onLogout} onToggleTheme={handleToggleTheme} /> )} <View style={[ base.tabBar, { backgroundColor: theme.card, borderTopColor: theme.border }, ]} > {[ { id: 'home', label: 'Главная', icon: '🏡' }, { id: 'expenses', label: 'Траты', icon: '💳' }, { id: 'goals', label: 'Цели', icon: '📌' }, { id: 'ai', label: 'ИИ-чат', icon: '🧠' }, { id: 'profile', label: 'Профиль', icon: '👤' }, ].map((tab) => { const isActive = activeTab === tab.id; return ( <TouchableOpacity key={tab.id} onPress={() => setActiveTab(tab.id)} style={[ base.tabButton, { backgroundColor: isActive ? theme.accentSoft : 'transparent' }, ]} > <Text style={[ { fontSize: 16 }, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.icon} </Text> <Text style={[ base.tabLabel, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.label} </Text> </TouchableOpacity> ); })} </View> </SafeAreaView>
);
};
// ---------- корневой компонент ----------
export default function App() {
const [users, setUsers] = useState([]);
const [session, setSession] = useState(null);
const [userData, setUserData] = useState(null);
const [themeMode, setThemeMode] = useState('dark');
const [loading, setLoading] = useState(true);
const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME;
useEffect(() => {
const init = async () => {
try {
const usersJson = await AsyncStorage.getItem(STORAGE_KEYS.USERS);
const sessionJson = await AsyncStorage.getItem(STORAGE_KEYS.SESSION);
const loadedUsers = usersJson ? JSON.parse(usersJson) : [];
setUsers(loadedUsers);
if (sessionJson) {
const s = JSON.parse(sessionJson);
const u = loadedUsers.find((user) => user.id === s.userId);
if (u) {
setSession(s);
const dataKey = FINAPP_DATA_${u.id};
const dataJson = await AsyncStorage.getItem(dataKey);
if (dataJson) {
const parsed = JSON.parse(dataJson);
setUserData(parsed);
if (parsed.settings && parsed.settings.themeMode) {
setThemeMode(parsed.settings.themeMode);
}
} else {
const defaultData = createDefaultUserData(themeMode);
setUserData(defaultData);
await AsyncStorage.setItem(dataKey, JSON.stringify(defaultData));
}
}
}
} catch (e) {
console.warn('Init error', e);
} finally {
setLoading(false);
}
};
init();
}, []);
useEffect(() => {
const persist = async () => {
if (!session || !userData) return;
try {
const key = FINAPP_DATA_${session.userId};
const payload = {
...userData,
settings: {
...(userData.settings || {}),
themeMode,
},
};
await AsyncStorage.setItem(key, JSON.stringify(payload));
} catch (e) {
console.warn('Save userData error', e);
}
};
persist();
}, [userData, session, themeMode]);
const handleLogin = async (user, profileFromRegistration = null) => {
const s = { userId: user.id };
setSession(s);
await AsyncStorage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(s));
const key = FINAPP_DATA_${user.id};
const dataJson = await AsyncStorage.getItem(key);
if (dataJson) {
const parsed = JSON.parse(dataJson);
setUserData(parsed);
if (parsed.settings && parsed.settings.themeMode) {
setThemeMode(parsed.settings.themeMode);
}
} else {
const defaultData = createDefaultUserData(themeMode, profileFromRegistration || {});
setUserData(defaultData);
await AsyncStorage.setItem(key, JSON.stringify(defaultData));
}
};
const handleRegister = async (newUser, profile) => {
const updatedUsers = [...users, newUser];
setUsers(updatedUsers);
await AsyncStorage.setItem(STORAGE_KEYS.USERS, JSON.stringify(updatedUsers));
await handleLogin(newUser, profile);
};
const handleLogout = async () => {
setSession(null);
setUserData(null);
await AsyncStorage.removeItem(STORAGE_KEYS.SESSION);
};
if (loading) {
return (
<SafeAreaView
style={[
base.container,
base.center,
{ backgroundColor: theme.background },
]}
>
<StatusBar
barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'}
/>
<Text style={[base.smallText, { color: theme.textMuted }]}>
Загрузка данных...
</Text>
</SafeAreaView>
);
}
if (!session || !userData) {
return (
<AuthScreen
users={users}
onLogin={handleLogin}
onRegister={handleRegister}
theme={theme}
/>
);
}
const currentUser = users.find((u) => u.id === session.userId) || {
id: session.userId,
email: 'user',
pin: '',
};
return (
<MainApp
currentUser={currentUser}
userData={userData}
setUserData={setUserData}
onLogout={handleLogout}
themeMode={themeMode}
setThemeMode={setThemeMode}
/>
);
}
import React, { useEffect, useState } from 'react';
import {
SafeAreaView,
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
FlatList,
StatusBar,
KeyboardAvoidingView,
Platform,
Switch,
Alert,
Image,
ActivityIndicator,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as ImagePicker from 'expo-image-picker';
import * as Speech from 'expo-speech';
// ВАЖНО: expo-mlkit-ocr не работает в Snack / Expo Go, поэтому не импортируем его
// import ExpoMlkitOcr from 'expo-mlkit-ocr';
// ---------- константы ----------
const STORAGE_KEYS = {
USERS: 'FINAPP_USERS',
SESSION: 'FINAPP_SESSION',
};
const DARK_THEME = {
mode: 'dark',
background: '#020617',
card: '#020617',
cardAlt: '#0B1220',
accent: '#22C55E',
accentSoft: 'rgba(34,197,94,0.22)',
text: '#F9FAFB',
textMuted: '#9CA3AF',
border: '#1F2937',
danger: '#EF4444',
success: '#22C55E',
warning: '#EAB308',
};
const LIGHT_THEME = {
mode: 'light',
background: '#F3F4F6',
card: '#FFFFFF',
cardAlt: '#E5E7EB',
accent: '#0F766E',
accentSoft: 'rgba(15,118,110,0.16)',
text: '#111827',
textMuted: '#6B7280',
border: '#D1D5DB',
danger: '#DC2626',
success: '#16A34A',
warning: '#CA8A04',
};
const DEFAULT_CURRENCY = '₸';
const DEFAULT_CATEGORIES = [
'Еда',
'Транспорт',
'Жильё',
'Здоровье',
'Подписки',
'Игры/развлечения',
'Покупки',
'Сбережения',
'Другое',
];
// ---------- стили ----------
const base = StyleSheet.create({
flex1: { flex: 1 },
center: { justifyContent: 'center', alignItems: 'center' },
container: { flex: 1 },
authContainer: { flex: 1, paddingHorizontal: 24, paddingTop: 32, paddingBottom: 24 },
title: { fontSize: 28, fontWeight: '700', marginBottom: 4 },
subtitle: { fontSize: 14, marginBottom: 24 },
input: {
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 14,
marginBottom: 12,
},
button: {
borderRadius: 18,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
marginTop: 4,
},
buttonText: { fontSize: 15, fontWeight: '600' },
link: { fontSize: 14, marginTop: 12, textAlign: 'center' },
errorText: { fontSize: 13, marginBottom: 8 },
header: {
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerTitle: { fontSize: 22, fontWeight: '700' },
headerSubtitle: { fontSize: 12 },
chipRow: { flexDirection: 'row', alignItems: 'center' },
chip: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
marginLeft: 8,
},
chipText: { fontSize: 12 },
content: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 4,
},
card: {
borderRadius: 20,
padding: 16,
marginBottom: 14,
shadowColor: '#000',
shadowOpacity: 0.14,
shadowRadius: 12,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
cardHeaderRow: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 6,
},
cardTitle: { fontSize: 16, fontWeight: '600' },
cardSubtitle: { fontSize: 12 },
row: { flexDirection: 'row', alignItems: 'center' },
rowBetween: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
pill: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
marginLeft: 6,
},
pillText: { fontSize: 11, fontWeight: '500' },
tabBar: {
flexDirection: 'row',
paddingHorizontal: 6,
paddingVertical: 8,
borderTopWidth: StyleSheet.hairlineWidth,
},
tabButton: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
borderRadius: 999,
marginHorizontal: 4,
},
tabLabel: { fontSize: 11, marginTop: 2 },
badgeDot: {
width: 8,
height: 8,
borderRadius: 999,
marginRight: 4,
},
smallText: { fontSize: 12 },
tinyText: { fontSize: 11 },
sectionTitle: { fontSize: 14, fontWeight: '600', marginBottom: 8 },
inputRow: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 14,
paddingHorizontal: 10,
marginBottom: 8,
},
inputInline: { flex: 1, paddingVertical: 6, fontSize: 13 },
expenseItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 10,
},
divider: {
height: StyleSheet.hairlineWidth,
marginVertical: 6,
},
tag: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
marginRight: 6,
marginTop: 4,
},
tagText: { fontSize: 11 },
chartRow: { marginTop: 6 },
chartBarBg: {
height: 10,
borderRadius: 999,
overflow: 'hidden',
},
chartBarFill: {
height: 10,
borderRadius: 999,
},
chatBubble: {
padding: 10,
borderRadius: 14,
marginVertical: 4,
maxWidth: '85%',
},
chatUser: { alignSelf: 'flex-end' },
chatAi: { alignSelf: 'flex-start' },
badge: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 999,
marginRight: 6,
marginBottom: 6,
},
avatarCircle: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
avatarImage: {
width: 44,
height: 44,
borderRadius: 22,
},
goalImage: {
width: '100%',
height: 120,
borderRadius: 12,
marginTop: 6,
},
});
// ---------- helpers ----------
const formatCurrency = (amount, currency = DEFAULT_CURRENCY) => {
const n = Number.isFinite(amount) ? amount : 0;
const rounded = Math.round(n);
return ${currency} ${rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')};
};
const formatDate = (isoString) => {
if (!isoString) return '';
const d = new Date(isoString);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('ru-RU', {
day: '2-digit',
month: 'short',
});
};
const getMonthKey = (date) =>
${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')};
const guessCategory = (title) => {
if (!title) return 'Другое';
const t = title.toLowerCase();
if (/(super|market|магазин|магнит|кошелек|grocery|food|еда|dodo|kfc|burger)/.test(t)) return 'Еда';
if (/(аптека|pharm|apteka|drug)/.test(t)) return 'Здоровье';
if (/(taxi|такси|yandex go|bolt|uber|bus|metro)/.test(t)) return 'Транспорт';
if (/(аренда|rent|ipotek|ипотека|квартира)/.test(t)) return 'Жильё';
if (/(netflix|spotify|youtube|yandex plus|подписка|subscription)/.test(t)) return 'Подписки';
if (/(steam|psn|xbox|game|игр)/.test(t)) return 'Игры/развлечения';
if (/(cafe|кафе|coffee|кофе|restaurant|ресторан)/.test(t)) return 'Еда';
if (/(ozon|wildberries|wb|aliexpress)/.test(t)) return 'Покупки';
return 'Другое';
};
const sumBy = (arr, fn) => arr.reduce((acc, item, idx) => acc + (fn(item, idx) || 0), 0);
const buildMonthlyTotals = (expenses) => {
const totals = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
const key = getMonthKey(d);
totals[key] = (totals[key] || 0) + (Number(e.amount) || 0);
});
return totals;
};
const buildCategoryTotalsForMonth = (expenses, monthKey) => {
const m = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
if (getMonthKey(d) !== monthKey) return;
const cat = e.category || 'Другое';
m[cat] = (m[cat] || 0) + (Number(e.amount) || 0);
});
return m;
};
const forecastNextMonth = (expenses) => {
const totalsMap = buildMonthlyTotals(expenses);
const keys = Object.keys(totalsMap).sort();
if (keys.length === 0) return 0;
const ys = keys.map((k) => totalsMap[k]);
if (keys.length === 1) return ys[0];
const n = ys.length;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumX2 = 0;
for (let i = 0; i < n; i += 1) {
const x = i;
const y = ys[i];
sumX += x;
sumY += y;
sumXY += x * y;
sumX2 += x * x;
}
const denom = n * sumX2 - sumX * sumX;
const slope = denom === 0 ? 0 : (n * sumXY - sumX * sumY) / denom;
const intercept = (sumY - slope * sumX) / n;
const forecast = intercept + slope * n;
return forecast > 0 ? forecast : ys[ys.length - 1];
};
const detectAnomalies = (expenses) => {
if (!expenses.length) return [];
const amounts = expenses.map((e) => Number(e.amount) || 0);
const avg = sumBy(amounts, (a) => a) / amounts.length;
const threshold = Math.max(avg * 2, 0);
return expenses.filter((e) => (Number(e.amount) || 0) > threshold && threshold > 0);
};
const calculateDailyStreak = (expenses, monthlyIncome) => {
if (!expenses.length || !monthlyIncome) return 0;
const dailyBudget = monthlyIncome / 30;
if (dailyBudget <= 0) return 0;
const byDay = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
const key = d.toISOString().substring(0, 10);
byDay[key] = (byDay[key] || 0) + (Number(e.amount) || 0);
});
const today = new Date();
let streak = 0;
for (let i = 0; i < 365; i += 1) {
const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000);
const key = d.toISOString().substring(0, 10);
const spent = byDay[key] || 0;
if (spent <= dailyBudget) {
streak += 1;
} else {
break;
}
}
return streak;
};
const countNoSpendDaysThisMonth = (expenses) => {
if (!expenses.length) return 0;
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const spentDays = new Set();
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
if (d.getFullYear() !== year || d.getMonth() !== month) return;
spentDays.add(d.getDate());
});
return daysInMonth - spentDays.size;
};
const calculateFinancialHealth = (userData) => {
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const monthlyIncome = Number(settings.monthlyIncome) || Number(userData.income) || 0;
const now = new Date();
const currentMonthKey = getMonthKey(now);
const monthTotals = buildMonthlyTotals(expenses);
const totalThisMonth = monthTotals[currentMonthKey] || 0;
const savings = Math.max(0, monthlyIncome - totalThisMonth);
const savingsRate = monthlyIncome > 0 ? savings / monthlyIncome : 0;
const unnecessary = sumBy(expenses, (e) => {
if (e.type === 'income') return 0;
const d = new Date(e.date || new Date());
if (getMonthKey(d) !== currentMonthKey) return 0;
const c = (e.category || '').toLowerCase();
if (
c.includes('игр') ||
c.includes('развлеч') ||
c.includes('подпис') ||
c.includes('покуп') ||
c.includes('кафе') ||
c.includes('bar') ||
c.includes('club')
) {
return Number(e.amount) || 0;
}
return 0;
});
const unnecessaryShare = monthlyIncome > 0 ? unnecessary / monthlyIncome : 0;
let score = 50;
score += Math.min(20, savingsRate * 40);
score -= Math.min(15, unnecessaryShare * 30);
if (userData.goals && userData.goals.length) score += 5;
if (expenses.length > 20) score += 5;
score = Math.max(0, Math.min(100, Math.round(score)));
let label = 'Нужно больше данных';
if (score >= 80) {
label = 'Отличное состояние';
} else if (score >= 60) {
label = 'Хорошее состояние';
} else if (score >= 40) {
label = 'Среднее, есть над чем поработать';
} else {
label = 'Слабое, нужен план';
}
return {
score,
label,
monthlyIncome,
totalThisMonth,
savings,
unnecessary,
};
};
// простое шифрование заметок по PIN (XOR + hex, не для продакшена)
const encryptNote = (plain, pin) => {
if (!plain) return '';
if (!pin) return plain;
const digits = pin.split('').map((d) => parseInt(d, 10) || 0);
if (!digits.length) return plain;
let out = '';
for (let i = 0; i < plain.length; i += 1) {
const code = plain.charCodeAt(i) ^ digits[i % digits.length];
out += code.toString(16).padStart(2, '0');
}
return out;
};
const decryptNote = (cipher, pin) => {
if (!cipher) return '';
if (!pin) return cipher;
const digits = pin.split('').map((d) => parseInt(d, 10) || 0);
if (!digits.length) return cipher;
try {
let out = '';
for (let i = 0; i < cipher.length; i += 2) {
const hex = cipher.substr(i, 2);
const code = parseInt(hex, 16) ^ digits[(i / 2) % digits.length];
out += String.fromCharCode(code);
}
return out;
} catch (e) {
return '';
}
};
const buildSmartReminders = (userData) => {
const reminders = [];
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const now = new Date();
const nowKey = getMonthKey(now);
const last7Total = sumBy(expenses, (e) => {
if (e.type === 'income') return 0;
const d = new Date(e.date || new Date());
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24);
if (diff >= 0 && diff <= 7) {
return Number(e.amount) || 0;
}
return 0;
});
const weeklyBudget = Number(settings.weeklyBudget) || 0;
if (weeklyBudget > 0 && last7Total > weeklyBudget) {
reminders.push(
За последние 7 дней вы уже вышли за недельный бюджет на ${formatCurrency( last7Total - weeklyBudget, settings.currency || DEFAULT_CURRENCY, )}.,
);
} else if (weeklyBudget > 0 && last7Total > weeklyBudget * 0.8) {
reminders.push('Вы приблизились к недельному бюджету — самое время притормозить расходы.');
}
(userData.goals || []).forEach((g) => {
const target = Number(g.targetAmount) || 0;
const saved = Number(g.savedAmount) || 0;
if (!target) return;
const d = g.deadline ? new Date(g.deadline) : null;
const left = target - saved;
if (left <= 0 || !d) return;
const daysLeft = Math.round((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (daysLeft <= 0) return;
const perDay = left / daysLeft;
if (perDay > 0) {
reminders.push(
До цели «${g.title}» осталось ${formatCurrency( left, settings.currency || DEFAULT_CURRENCY, )}. Нужно откладывать примерно ${formatCurrency( perDay, settings.currency || DEFAULT_CURRENCY, )} в день.,
);
}
});
const monthTotals = buildMonthlyTotals(expenses);
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevKey = getMonthKey(prevMonth);
const totalNow = monthTotals[nowKey] || 0;
const totalPrev = monthTotals[prevKey] || 0;
if (totalPrev > 0 && totalNow > totalPrev * 1.15) {
const diff = totalNow - totalPrev;
reminders.push(
В этом месяце ваши траты уже на ${formatCurrency( diff, settings.currency || DEFAULT_CURRENCY, )} больше, чем в прошлом.,
);
}
if (!reminders.length) {
reminders.push('Вы молодец — критичных напоминаний сейчас нет 🎉');
}
return reminders;
};
const buildInsightsSummary = (userData) => {
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const now = new Date();
const currentMonthKey = getMonthKey(now);
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevKey = getMonthKey(prevMonth);
const monthTotals = buildMonthlyTotals(expenses);
const totalNow = monthTotals[currentMonthKey] || 0;
const totalPrev = monthTotals[prevKey] || 0;
const catNow = buildCategoryTotalsForMonth(expenses, currentMonthKey);
const catPrev = buildCategoryTotalsForMonth(expenses, prevKey);
const entriesNow = Object.entries(catNow);
const topCat = entriesNow.sort((a, b) => b[1] - a[1])[0];
const currency = settings.currency || DEFAULT_CURRENCY;
const anomalies = detectAnomalies(expenses).slice(0, 3);
const lines = [];
if (totalPrev > 0 && totalNow > 0) {
const delta = totalNow - totalPrev;
const pct = (delta / totalPrev) * 100;
if (Math.abs(pct) >= 5) {
if (pct > 0) {
lines.push(
Общие расходы выросли на ${pct.toFixed(1)}% по сравнению с прошлым месяцем (${formatCurrency( totalNow, currency, )} против ${formatCurrency(totalPrev, currency)}).,
);
} else {
lines.push(
Вы тратите на ${Math.abs(pct).toFixed( 1, )}% меньше, чем в прошлом месяце — отличный прогресс 👏,
);
}
}
}
if (topCat) {
lines.push(
Больше всего вы тратите на категорию «${topCat[0]}» — ${formatCurrency( topCat[1], currency, )} в этом месяце.,
);
}
Object.keys(catNow).forEach((c) => {
const nowVal = catNow[c];
const prevVal = catPrev[c] || 0;
if (prevVal <= 0) return;
const pct = (nowVal - prevVal) / prevVal;
if (pct >= 0.25 && nowVal > 0) {
lines.push(
Категория «${c}» выросла на ${Math.round( pct * 100, )}% относительно прошлого месяца — стоит проверить, можно ли там сэкономить.,
);
}
});
if (anomalies.length) {
const txt = anomalies
.map((a) => "${a.title}" на ${formatCurrency(a.amount, currency)})
.join(', ');
lines.push(Обнаружены разовые крупные траты: ${txt}. Убедитесь, что они действительно нужны.);
}
if (!lines.length) {
lines.push('Пока недостаточно данных для глубокого анализа. Начните добавлять траты 🙂');
}
return lines;
};
const generateAiAnswer = (promptRaw, userData) => {
const prompt = promptRaw.toLowerCase();
const health = calculateFinancialHealth(userData);
const insights = buildInsightsSummary(userData);
const currency = (userData.settings && userData.settings.currency) || DEFAULT_CURRENCY;
const expenses = userData.expenses || [];
const monthlyTotals = buildMonthlyTotals(expenses);
const nowKey = getMonthKey(new Date());
const totalThisMonth = monthlyTotals[nowKey] || 0;
if (!expenses.length && !prompt.includes('регистрац')) {
return 'У меня пока нет ваших данных. Добавьте несколько трат и доходов — тогда я смогу давать персональные советы.';
}
if (prompt.includes('сократить') || prompt.includes('эконом') || prompt.includes('сэкономить')) {
const catNow = buildCategoryTotalsForMonth(expenses, nowKey);
const entries = Object.entries(catNow).sort((a, b) => b[1] - a[1]);
const top3 = entries.slice(0, 3);
const tips = top3
.map(
([c, v]) =>
• «${c}» — ${formatCurrency( v, currency, )} в месяц. Попробуйте снизить хотя бы на 10–20%.,
)
.join('\n');
return (
Сейчас ваши траты за месяц: ${formatCurrency( totalThisMonth, currency, )}. Чтобы сократить их, лучше всего начать с самых крупных категорий:\n\n${tips}\n\n +
'Дополнительно:\n' +
'• установите недельный бюджет и фиксируйте все траты;\n' +
'• избегайте спонтанных покупок — делайте «паузу 24 часа»;\n' +
'• для подписок — оставьте только то, чем пользуетесь каждую неделю.'
);
}
if (prompt.includes('как распределить') || prompt.includes('зарплат')) {
const match = promptRaw.match(/(\d[\d\s]{2,})/);
const income = match ? parseInt(match[1].replace(/\s/g, ''), 10) : health.monthlyIncome || 0;
if (!income) {
return 'Напишите, пожалуйста, размер зарплаты (например: «Как распределить зарплату 300 000?»), или задайте месячный доход в настройках. Тогда я предложу детальный план распределения.';
}
const needs = income * 0.5;
const wants = income * 0.3;
const saving = income * 0.2;
return (
Классическая модель 50/30/20 для зарплаты ${formatCurrency(income, currency)}:\n\n +
• 50% (${formatCurrency(needs, currency)}) — обязательные расходы (жильё, еда, транспорт, кредиты);\n +
• 30% (${formatCurrency(wants, currency)}) — «хотелки» и развлечения;\n +
• 20% (${formatCurrency(saving, currency)}) — сбережения и цели.\n\n +
'Вы можете адаптировать: если есть кредиты — уменьшите «хотелки» до 10–15% и направьте разницу на досрочное погашение и накопления.'
);
}
if (prompt.includes('стоит ли покупать') || prompt.includes('покупать')) {
return (
'Задайте себе три вопроса перед покупкой:\n' +
'1) Нужна ли вещь прямо сейчас или это импульс?\n' +
'2) Сколько часов вашей работы она стоит?\n' +
'3) Через месяц вы будете так же рады этой покупке?\n\n' +
'Если сумма покупки >10% от месячного дохода — хорошо бы выдержать «паузу 24 часа». ' +
'Также посмотрите, не мешает ли эта покупка вашим финансовым целям (накоплениям и т.п.).'
);
}
if (prompt.includes('что если') || prompt.includes('если буду')) {
return (
'Функция «Что если?» уже доступна на вкладке «Цели» — там можно смоделировать, что будет, если сократить траты на кафе или начать откладывать больше. ' +
'Если хотите, сформулируйте сценарий конкретно (например: «Что будет, если каждый месяц откладывать на 20 000 больше?»), и я прикину эффект в чате.'
);
}
const baseInsights = insights.slice(0, 2).join('\n\n');
return (
Краткий обзор вашего финансового состояния:\n +
• Индекс финансового здоровья: ${health.score}/100 (${health.label}).\n +
• Траты в этом месяце: ${formatCurrency(totalThisMonth, currency)}.\n +
• Потенциальные сбережения: ${formatCurrency(health.savings, currency)}.\n\n +
${baseInsights}\n\n +
'Задавайте мне конкретные вопросы: например, «Как сократить расходы на 20%?» или ' +
'«Помоги перераспределить бюджет с учётом цели на 800 000».'
);
};
// helper для дефолтных данных пользователя
const createDefaultUserData = (themeMode, profile = {}) => ({
income: 0,
expenses: [],
goals: [],
subscriptions: [],
settings: {
monthlyIncome: 0,
weeklyBudget: 0,
currency: DEFAULT_CURRENCY,
antiImpulseEnabled: false,
antiImpulseThreshold: 0,
themeMode: themeMode || 'dark',
},
profile,
secureNote: '',
});
// helper для выбора картинки из галереи
const pickImageFromLibrary = async () => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Нет доступа', 'Разрешите доступ к фото в настройках устройства.');
return null;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.8,
});
if (result.canceled) return null;
const asset = result.assets && result.assets[0];
if (!asset) return null;
return asset.uri;
};
// ---------- OCR + ИИ-проверка чеков ----------
// ⚠️ Snack / Expo Go не поддерживает expo-mlkit-ocr из коробки.
// Поэтому здесь заглушка, чтобы код работал в онлайн-Snack.
// В реальном приложении на EAS dev-client можно вернуть оригинальный код с ML Kit.
const runOcrOnReceiptImage = async (uri) => {
console.log('runOcrOnReceiptImage: stub on Snack, uri:', uri);
if (Platform.OS === 'web') {
Alert.alert(
'OCR недоступен',
'Распознавание чеков не поддерживается в веб-версии Snack. Введите сумму вручную.',
);
} else {
Alert.alert(
'OCR недоступен в Snack',
'Для сканирования чеков через ML Kit нужен кастомный dev-клиент. В демо на Snack сумму нужно вводить вручную.',
);
}
// Возвращаем текст, чтобы показать превью, но сумма из него не извлечётся
return '(OCR недоступен в Snack — введите сумму вручную)';
};
// «Умный» парсер текста чека: пытается понять именно итоговую сумму
const parseReceiptOcrText = (text) => {
if (!text) return null;
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) return null;
// Денежный паттерн: 1 234,56 / 1234.56 / 12 345 / 12345 и т.п.
const MONEY_REGEX =
/\d{1,3}(?:[ \u00A0]\d{3})*(?:[.,]\d{1,2})?|\d+[.,]\d{1,2}/g;
const parseMoney = (raw) => {
if (!raw) return NaN;
let cleaned = String(raw)
.replace(/\u00A0/g, ' ')
.replace(/[^\d.,\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
textif (!cleaned) return NaN; cleaned = cleaned.replace(/\s/g, ''); if (cleaned.includes(',') && cleaned.includes('.')) { const lastComma = cleaned.lastIndexOf(','); const lastDot = cleaned.lastIndexOf('.'); if (lastComma > lastDot) { cleaned = cleaned.replace(/\./g, '').replace(',', '.'); } else { cleaned = cleaned.replace(/,/g, ''); } } else if (cleaned.includes(',')) { cleaned = cleaned.replace(',', '.'); } const value = parseFloat(cleaned); return Number.isFinite(value) ? value : NaN;
};
const isTotalLine = (line) => {
const lower = line.toLowerCase().replace(/\s+/g, ' ');
return (
/итог(о)?/.test(lower) ||
/к оплате/.test(lower) ||
/сумма к оплате/.test(lower) ||
/итог по чеку/.test(lower) ||
/total/.test(lower)
);
};
let amount = null;
// 1) Ищем строку с ИТОГ / К ОПЛАТЕ и берём сумму оттуда
for (const line of lines) {
if (!isTotalLine(line)) continue;
textconst matches = line.match(MONEY_REGEX); if (matches && matches.length) { const raw = matches[matches.length - 1]; const v = parseMoney(raw); if (Number.isFinite(v) && v > 0) { amount = v; break; } }
}
// 2) Если не нашли — смотрим последние 3–5 строк
if (amount == null) {
const tail = lines.slice(-5);
const candidates = [];
texttail.forEach((line) => { const matches = line.match(MONEY_REGEX); if (!matches) return; matches.forEach((m) => { const v = parseMoney(m); if (Number.isFinite(v) && v > 0 && v < 1e7) { candidates.push(v); } }); }); if (candidates.length) { amount = Math.max(...candidates); }
}
// 3) Совсем fallback — ищем по всему тексту
if (amount == null) {
const candidates = [];
lines.forEach((line) => {
const matches = line.match(MONEY_REGEX);
if (!matches) return;
matches.forEach((m) => {
const v = parseMoney(m);
if (Number.isFinite(v) && v > 0 && v < 1e7) {
candidates.push(v);
}
});
});
textif (candidates.length) { amount = Math.max(...candidates); }
}
// 4) Название магазина / покупки
let titleLine = '';
for (const line of lines) {
const letters = line.replace(/[^A-Za-zА-Яа-яЁё]/g, '');
if (!letters.length) continue;
textif (isTotalLine(line)) continue; if (/кассир|смена|чек|фискальн|ккм|рнк|зн/i.test(line)) continue; titleLine = line; break;
}
if (!titleLine) {
titleLine = 'Покупка по чеку';
}
const category = guessCategory(titleLine);
return {
title: titleLine,
amount: Number.isFinite(amount) && amount > 0 ? amount : 0,
category,
};
};
// ---------- небольшие компоненты ----------
const CategoryBarChart = ({ data, theme }) => {
if (!data || !data.length) return null;
const max = Math.max(...data.map((d) => d.value || 0));
if (!max) return null;
return (
<View style={base.chartRow}>
{data.map((item) => {
const pct = (item.value / max) * 100;
return (
<View key={item.label} style={{ marginBottom: 6 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>{item.label}</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{formatCurrency(item.value)}
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 3 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: theme.accent,
width: ${pct}%,
},
]}
/>
</View>
</View>
);
})}
</View>
);
};
const SimpleMonthlyChart = ({ expenses, theme }) => {
const totalsMap = buildMonthlyTotals(expenses || []);
const keys = Object.keys(totalsMap).sort();
if (!keys.length) return null;
const items = keys.slice(-6).map((k) => ({ key: k, value: totalsMap[k] }));
const max = Math.max(...items.map((i) => i.value));
if (!max) return null;
return (
<View style={{ marginTop: 4 }}>
{items.map((item) => {
const [year, month] = item.key.split('-');
const label = ${month}.${year.slice(-2)};
const pct = (item.value / max) * 100;
return (
<View key={item.key} style={{ marginBottom: 4 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>{label}</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{formatCurrency(item.value)}
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 3 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: theme.accentSoft,
width: ${pct}%,
},
]}
/>
</View>
</View>
);
})}
</View>
);
};
const SpendingGauge = ({ monthlyIncome, spent, theme, currency }) => {
if (!monthlyIncome) return null;
const ratio = monthlyIncome > 0 ? spent / monthlyIncome : 0;
const pct = Math.max(0, Math.min(1, ratio));
const labelPct = (pct * 100).toFixed(0);
return (
<View style={{ marginTop: 10 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>Расходы от дохода</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{labelPct}% ({formatCurrency(spent, currency)})
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 4, height: 12 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: ratio > 1 ? theme.danger : theme.accent,
width: ${pct * 100}%,
},
]}
/>
</View>
{ratio > 1 && (
<Text style={[base.tinyText, { color: theme.danger, marginTop: 4 }]}>
Вы вышли за рамки месячного дохода — стоит пересмотреть план.
</Text>
)}
</View>
);
};
// ---------- экраны ----------
const AuthScreen = ({ users, onLogin, onRegister, theme }) => {
const [mode, setMode] = useState('login'); // 'login' | 'register'
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [pin, setPin] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [phone, setPhone] = useState('');
const [birthDate, setBirthDate] = useState('');
const [avatarUri, setAvatarUri] = useState('');
const [error, setError] = useState('');
const toggleMode = () => {
setError('');
setMode((m) => (m === 'login' ? 'register' : 'login'));
};
const handleLoginPress = () => {
setError('');
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail || !password) {
setError('Заполните email и пароль.');
return;
}
const user = users.find((u) => u.email === normalizedEmail);
if (!user || user.password !== password) {
setError('Неверный email или пароль.');
return;
}
onLogin(user);
};
const handleRegisterPress = () => {
setError('');
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail || !password) {
setError('Заполните email и пароль.');
return;
}
if (!normalizedEmail.includes('@')) {
setError('Некорректный email.');
return;
}
if (password.length < 4) {
setError('Пароль должен быть не короче 4 символов.');
return;
}
if (!firstName.trim()) {
setError('Укажите имя.');
return;
}
if (!phone.trim()) {
setError('Укажите номер телефона.');
return;
}
if (users.some((u) => u.email === normalizedEmail)) {
setError('Пользователь с таким email уже существует.');
return;
}
const newUser = {
id: u_${Date.now()},
email: normalizedEmail,
password,
pin: pin || '',
createdAt: new Date().toISOString(),
};
const profile = {
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim(),
birthDate: birthDate.trim(),
avatarUri: avatarUri || '',
};
onRegister(newUser, profile);
};
const handlePickAvatar = async () => {
const uri = await pickImageFromLibrary();
if (uri) {
setAvatarUri(uri);
}
};
return (
<SafeAreaView
style={[
base.container,
{ backgroundColor: theme.background },
]}
>
<StatusBar
barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'}
/>
<KeyboardAvoidingView
style={base.flex1}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={[base.authContainer]}>
<View style={{ marginBottom: 32 }}>
<Text style={[base.title, { color: theme.text }]}>
Финансовый ИИ-помощник
</Text>
<Text style={[base.subtitle, { color: theme.textMuted }]}>
Управляй доходами, расходами, целями и сканируй чеки с ИИ.
</Text>
</View>
text<View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[ base.cardTitle, { color: theme.text }, ]} > {mode === 'login' ? 'Вход' : 'Регистрация'} </Text> <View style={[ base.pill, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.pillText, { color: theme.accent }, ]} > ⚡ ИИ + чек-сканер </Text> </View> </View> {error ? ( <Text style={[base.errorText, { color: theme.danger }]}>{error}</Text> ) : null} {mode === 'register' && ( <> <View style={base.row}> <TouchableOpacity onPress={handlePickAvatar}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> + </Text> </View> )} </TouchableOpacity> <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото профиля (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте аватар, чтобы приложение стало более личным. </Text> </View> </View> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} keyboardType="phone-pad" value={phone} onChangeText={setPhone} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </> )} <TextInput placeholder="Email" placeholderTextColor={theme.textMuted} autoCapitalize="none" keyboardType="email-address" value={email} onChangeText={setEmail} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Пароль" placeholderTextColor={theme.textMuted} secureTextEntry value={password} onChangeText={setPassword} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> {mode === 'register' && ( <TextInput placeholder="PIN (необязательно, для шифрования заметок)" placeholderTextColor={theme.textMuted} value={pin} onChangeText={setPin} keyboardType="number-pad" maxLength={6} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> )} <TouchableOpacity onPress={mode === 'login' ? handleLoginPress : handleRegisterPress} style={[ base.button, { backgroundColor: theme.accent, marginTop: 8 }, ]} > <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {mode === 'login' ? 'Войти' : 'Зарегистрироваться'} </Text> </TouchableOpacity> <TouchableOpacity onPress={toggleMode}> <Text style={[ base.link, { color: theme.textMuted }, ]} > {mode === 'login' ? 'Нет аккаунта? Создать' : 'Уже есть аккаунт? Войти'} </Text> </TouchableOpacity> </View> <View style={{ marginTop: 24 }}> <Text style={[ base.smallText, { color: theme.textMuted }, ]} > Данные хранятся только локально на устройстве (AsyncStorage). Для реального приложения стоит добавить серверный бэкап и более серьёзную криптографию. </Text> </View> </View> </KeyboardAvoidingView> </SafeAreaView>
);
};
const HomeScreen = ({ userData, theme }) => {
const health = calculateFinancialHealth(userData);
const insights = buildInsightsSummary(userData);
const reminders = buildSmartReminders(userData);
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const now = new Date();
const currentMonthKey = getMonthKey(now);
const catNowMap = buildCategoryTotalsForMonth(expenses, currentMonthKey);
const categoryData = Object.entries(catNowMap)
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value);
const forecast = forecastNextMonth(expenses);
const streak = calculateDailyStreak(
expenses,
Number(settings.monthlyIncome) || Number(userData.income) || 0,
);
const noSpendDays = countNoSpendDaysThisMonth(expenses);
const profile = userData.profile || {};
const firstName = profile.firstName || '';
const badges = [];
if (health.score >= 80) badges.push('Экономист месяца');
if (streak >= 7) badges.push('7 дней в плюсе');
if (noSpendDays >= 5) badges.push('Дни без трат');
if (expenses.length >= 50) badges.push('Финансовый самурай');
return (
<ScrollView
style={[
base.content,
{ backgroundColor: theme.background },
]}
showsVerticalScrollIndicator={false}
>
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.rowBetween}>
<View style={{ flex: 1 }}>
<Text style={[base.cardSubtitle, { color: theme.textMuted }]}>
Индекс финансового здоровья
</Text>
<Text
style={[
{ fontSize: 32, fontWeight: '700', marginTop: 4 },
{ color: theme.text },
]}
>
{health.score}
<Text style={{ fontSize: 16 }}>/100</Text>
</Text>
{firstName ? (
<Text
style={[
base.tinyText,
{ color: theme.textMuted, marginTop: 4 },
]}
>
{firstName}, это ваш персональный индекс.
</Text>
) : null}
</View>
<View
style={[
{
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
},
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.tinyText, { color: theme.textMuted }]}>Статус</Text>
<Text
style={[
base.smallText,
{ color: theme.text, fontWeight: '600', marginTop: 2 },
]}
>
{health.label}
</Text>
</View>
</View>
text<View style={{ marginTop: 10 }}> <View style={base.rowBetween}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Доход / мес.</Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {health.monthlyIncome ? formatCurrency(health.monthlyIncome, currency) : 'Не задан'} </Text> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Траты в этом месяце </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(health.totalThisMonth, currency)} </Text> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Сбережения</Text> <Text style={[ base.smallText, { color: health.savings >= 0 ? theme.success : theme.danger, marginTop: 2, }, ]} > {formatCurrency(health.savings, currency)} </Text> </View> <SpendingGauge monthlyIncome={health.monthlyIncome} spent={health.totalThisMonth} theme={theme} currency={currency} /> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Привычки</Text> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Серия дней в рамках бюджета: {streak} </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Дней без трат в этом месяце: {noSpendDays} </Text> </View> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Бейджи</Text> <View style={[base.row, { flexWrap: 'wrap', marginTop: 4 }]}> {badges.length ? ( badges.map((b) => ( <View key={b} style={[ base.badge, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.tinyText, { color: theme.accent }, ]} > ✨ {b} </Text> </View> )) ) : ( <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Собирайте награды, держась в рамках бюджета. </Text> )} </View> </View> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>AI-инсайты</Text> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>🧩 Анализ</Text> </View> </View> {insights.slice(0, 3).map((line, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{line}</Text> </View> ))} </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Категории</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Текущий месяц </Text> </View> <CategoryBarChart data={categoryData} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Динамика</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Прогноз: {formatCurrency(forecast, currency)} </Text> </View> <SimpleMonthlyChart expenses={expenses} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Умные напоминания</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔔</Text> </View> {reminders.map((r, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{r}</Text> </View> ))} </View> </ScrollView>
);
};
const ExpensesScreen = ({ userData, onUpdateUserData, theme }) => {
const [title, setTitle] = useState('');
const [amount, setAmount] = useState('');
const [category, setCategory] = useState('');
const [isIncome, setIsIncome] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [lastReceiptPreview, setLastReceiptPreview] = useState(null); // { uri, text }
const expenses = [...(userData.expenses || [])].sort(
(a, b) => new Date(b.date || 0).getTime() - new Date(a.date || 0).getTime(),
);
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const antiImpulseEnabled = settings.antiImpulseEnabled;
const antiImpulseThreshold = Number(settings.antiImpulseThreshold) || 0;
const addEntry = (n, guessed, customTitle, extraMeta) => {
const entry = {
id: exp_${Date.now()},
title: (customTitle || title).trim(),
amount: n,
category: guessed,
type: isIncome ? 'income' : 'expense',
date: new Date().toISOString(),
...(extraMeta ? { meta: extraMeta } : {}),
};
onUpdateUserData((prev) => ({
...prev,
expenses: [...(prev.expenses || []), entry],
}));
setTitle('');
setAmount('');
setCategory('');
setIsIncome(false);
};
const handleAdd = () => {
const n = parseFloat(String(amount).replace(',', '.'));
if (!title || !Number.isFinite(n) || n <= 0) {
Alert.alert('Ошибка', 'Введите название и сумму.');
return;
}
const guessed = category || guessCategory(title);
textif (!isIncome && antiImpulseEnabled && antiImpulseThreshold > 0 && n >= antiImpulseThreshold) { Alert.alert( 'Анти-импульсная защита', `Покупка на ${formatCurrency( n, currency, )} превышает ваш лимит в ${formatCurrency(antiImpulseThreshold, currency)}.\n\n` + 'Вы уверены, что хотите продолжить?', [ { text: 'Отменить', style: 'cancel' }, { text: 'Купить', style: 'destructive', onPress: () => addEntry(n, guessed), }, ], ); return; } addEntry(n, guessed);
};
const handleScanReceipt = async () => {
try {
setIsScanning(true);
const perm = await ImagePicker.requestCameraPermissionsAsync();
if (!perm.granted) {
Alert.alert('Нет доступа', 'Разрешите доступ к камере в настройках устройства.');
return;
}
textconst result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8, }); if (result.canceled) return; const asset = result.assets && result.assets[0]; if (!asset) return; const uri = asset.uri; const ocrText = await runOcrOnReceiptImage(uri); setLastReceiptPreview({ uri, text: ocrText }); const parsed = parseReceiptOcrText(ocrText); if (!parsed || !parsed.amount || parsed.amount <= 0) { Alert.alert( 'Не удалось распознать чек', 'На Snack распознавание суммы недоступно. Введите сумму вручную в блоке «Новая запись».', ); return; } const guessedCategory = parsed.category || guessCategory(parsed.title); const n = parsed.amount; // Авто-добавление в расходы onUpdateUserData((prev) => ({ ...prev, expenses: [ ...(prev.expenses || []), { id: `exp_receipt_${Date.now()}`, title: parsed.title || 'Покупка по чеку', amount: n, category: guessedCategory, type: 'expense', date: new Date().toISOString(), meta: { source: 'receipt', ocrText, imageUri: uri, }, }, ], })); Alert.alert( 'Чек добавлен', `Мы распознали сумму ${formatCurrency( n, currency, )} и категорию «${guessedCategory}».\nЗапись добавлена в историю расходов.`, ); } catch (e) { console.warn('Receipt scan error', e); Alert.alert('Ошибка', 'Не удалось обработать чек. Попробуйте ещё раз.'); } finally { setIsScanning(false); }
};
const totalExpenses = sumBy(expenses, (e) =>
e.type === 'income' ? 0 : Number(e.amount) || 0,
);
const totalIncome = sumBy(expenses, (e) =>
e.type === 'income' ? Number(e.amount) || 0 : 0,
);
return (
<View
style={[
base.content,
{ backgroundColor: theme.background },
]}
>
<ScrollView
style={{ flex: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* ИИ-проверка чеков */}
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<View style={{ flex: 1, paddingRight: 6 }}>
<Text style={[base.cardTitle, { color: theme.text }]}>
ИИ-проверка чеков
</Text>
<Text style={[base.tinyText, { color: theme.textMuted, marginTop: 2 }]}>
Сделайте фото чека — мы распознаем сумму и автоматически добавим расход.
</Text>
</View>
<View style={[base.pill, { backgroundColor: theme.accentSoft }]}>
<Text style={[base.pillText, { color: theme.accent }]}>🤖 Чек-сканер</Text>
</View>
</View>
text<TouchableOpacity onPress={handleScanReceipt} disabled={isScanning} style={[ base.button, { backgroundColor: isScanning ? theme.cardAlt : theme.accent, marginTop: 8, flexDirection: 'row', }, ]} > {isScanning && ( <ActivityIndicator size="small" style={{ marginRight: 8 }} color="#FFFFFF" /> )} <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {isScanning ? 'Сканируем чек...' : 'Сканировать чек с ИИ'} </Text> </TouchableOpacity> {lastReceiptPreview && ( <View style={[base.row, { marginTop: 10 }]}> <Image source={{ uri: lastReceiptPreview.uri }} style={{ width: 56, height: 56, borderRadius: 12, marginRight: 8 }} /> <View style={{ flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Последний чек (OCR) </Text> <Text numberOfLines={3} style={[base.smallText, { color: theme.text, marginTop: 2 }]} > {lastReceiptPreview.text || 'Текст не распознан'} </Text> </View> </View> )} </View> {/* Ручное добавление записи */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Новая запись</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}> Доход </Text> <Switch value={isIncome} onValueChange={setIsIncome} thumbColor={isIncome ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Магазин / описание" placeholderTextColor={theme.textMuted} value={title} onChangeText={(text) => { setTitle(text); if (!category) { setCategory(guessCategory(text)); } }} style={[base.inputInline, { color: theme.text }]} /> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} keyboardType="numeric" value={amount} onChangeText={setAmount} style={[base.inputInline, { color: theme.text }]} /> <Text style={[base.smallText, { color: theme.textMuted }]}>{currency}</Text> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Категория</Text> <ScrollView horizontal showsHorizontalScrollIndicator={false}> {DEFAULT_CATEGORIES.map((c) => ( <TouchableOpacity key={c} onPress={() => setCategory(c)} style={[ base.tag, { backgroundColor: category === c ? theme.accentSoft : theme.cardAlt, borderWidth: 1, borderColor: category === c ? theme.accent : theme.border, }, ]} > <Text style={[ base.tagText, { color: category === c ? theme.accent : theme.textMuted, }, ]} > {c} </Text> </TouchableOpacity> ))} </ScrollView> </View> <TouchableOpacity onPress={handleAdd} style={[ base.button, { backgroundColor: theme.accent, marginTop: 12 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить </Text> </TouchableOpacity> </View> {/* Итоги */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.rowBetween}> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего расходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalExpenses, currency)} </Text> </View> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего доходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalIncome, currency)} </Text> </View> </View> </View> {/* История */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>История</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> {expenses.length} записей </Text> </View> {expenses.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Добавьте первую трату или доход. </Text> ) : ( <FlatList data={expenses} keyExtractor={(item) => item.id} scrollEnabled={false} renderItem={({ item }) => { const isImpulseHighlight = item.type !== 'income' && antiImpulseEnabled && antiImpulseThreshold > 0 && Number(item.amount) >= antiImpulseThreshold; const isReceipt = item.meta && item.meta.source === 'receipt'; return ( <View> <View style={base.expenseItem}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> {item.title} </Text> <View style={base.row}> генерация кода прервалась отправь либо полный код либо продолжение кода
Вот полный App.js, уже адаптированный под Snack / Expo Go (без expo-mlkit-ocr).
Просто целиком замени свой файл этим кодом 👇
jsximport React, { useEffect, useState } from 'react'; import { SafeAreaView, View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView, FlatList, StatusBar, KeyboardAvoidingView, Platform, Switch, Alert, Image, ActivityIndicator, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as ImagePicker from 'expo-image-picker'; import * as Speech from 'expo-speech'; // ВАЖНО: expo-mlkit-ocr не работает в Snack / Expo Go, поэтому не импортируем его // import ExpoMlkitOcr from 'expo-mlkit-ocr'; // ---------- константы ---------- const STORAGE_KEYS = { USERS: 'FINAPP_USERS', SESSION: 'FINAPP_SESSION', }; const DARK_THEME = { mode: 'dark', background: '#020617', card: '#020617', cardAlt: '#0B1220', accent: '#22C55E', accentSoft: 'rgba(34,197,94,0.22)', text: '#F9FAFB', textMuted: '#9CA3AF', border: '#1F2937', danger: '#EF4444', success: '#22C55E', warning: '#EAB308', }; const LIGHT_THEME = { mode: 'light', background: '#F3F4F6', card: '#FFFFFF', cardAlt: '#E5E7EB', accent: '#0F766E', accentSoft: 'rgba(15,118,110,0.16)', text: '#111827', textMuted: '#6B7280', border: '#D1D5DB', danger: '#DC2626', success: '#16A34A', warning: '#CA8A04', }; const DEFAULT_CURRENCY = '₸'; const DEFAULT_CATEGORIES = [ 'Еда', 'Транспорт', 'Жильё', 'Здоровье', 'Подписки', 'Игры/развлечения', 'Покупки', 'Сбережения', 'Другое', ]; // ---------- стили ---------- const base = StyleSheet.create({ flex1: { flex: 1 }, center: { justifyContent: 'center', alignItems: 'center' }, container: { flex: 1 }, authContainer: { flex: 1, paddingHorizontal: 24, paddingTop: 32, paddingBottom: 24 }, title: { fontSize: 28, fontWeight: '700', marginBottom: 4 }, subtitle: { fontSize: 14, marginBottom: 24 }, input: { borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, fontSize: 14, marginBottom: 12, }, button: { borderRadius: 18, paddingVertical: 12, alignItems: 'center', justifyContent: 'center', marginTop: 4, }, buttonText: { fontSize: 15, fontWeight: '600' }, link: { fontSize: 14, marginTop: 12, textAlign: 'center' }, errorText: { fontSize: 13, marginBottom: 8 }, header: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, headerTitle: { fontSize: 22, fontWeight: '700' }, headerSubtitle: { fontSize: 12 }, chipRow: { flexDirection: 'row', alignItems: 'center' }, chip: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999, marginLeft: 8, }, chipText: { fontSize: 12 }, content: { flex: 1, paddingHorizontal: 16, paddingTop: 4, paddingBottom: 4, }, card: { borderRadius: 20, padding: 16, marginBottom: 14, shadowColor: '#000', shadowOpacity: 0.14, shadowRadius: 12, shadowOffset: { width: 0, height: 4 }, elevation: 4, }, cardHeaderRow: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 6, }, cardTitle: { fontSize: 16, fontWeight: '600' }, cardSubtitle: { fontSize: 12 }, row: { flexDirection: 'row', alignItems: 'center' }, rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, pill: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, marginLeft: 6, }, pillText: { fontSize: 11, fontWeight: '500' }, tabBar: { flexDirection: 'row', paddingHorizontal: 6, paddingVertical: 8, borderTopWidth: StyleSheet.hairlineWidth, }, tabButton: { flex: 1, alignItems: 'center', paddingVertical: 8, borderRadius: 999, marginHorizontal: 4, }, tabLabel: { fontSize: 11, marginTop: 2 }, badgeDot: { width: 8, height: 8, borderRadius: 999, marginRight: 4, }, smallText: { fontSize: 12 }, tinyText: { fontSize: 11 }, sectionTitle: { fontSize: 14, fontWeight: '600', marginBottom: 8 }, inputRow: { flexDirection: 'row', alignItems: 'center', borderRadius: 14, paddingHorizontal: 10, marginBottom: 8, }, inputInline: { flex: 1, paddingVertical: 6, fontSize: 13 }, expenseItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10, }, divider: { height: StyleSheet.hairlineWidth, marginVertical: 6, }, tag: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, marginRight: 6, marginTop: 4, }, tagText: { fontSize: 11 }, chartRow: { marginTop: 6 }, chartBarBg: { height: 10, borderRadius: 999, overflow: 'hidden', }, chartBarFill: { height: 10, borderRadius: 999, }, chatBubble: { padding: 10, borderRadius: 14, marginVertical: 4, maxWidth: '85%', }, chatUser: { alignSelf: 'flex-end' }, chatAi: { alignSelf: 'flex-start' }, badge: { paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, marginRight: 6, marginBottom: 6, }, avatarCircle: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', }, avatarImage: { width: 44, height: 44, borderRadius: 22, }, goalImage: { width: '100%', height: 120, borderRadius: 12, marginTop: 6, }, }); // ---------- helpers ---------- const formatCurrency = (amount, currency = DEFAULT_CURRENCY) => { const n = Number.isFinite(amount) ? amount : 0; const rounded = Math.round(n); return `${currency} ${rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}`; }; const formatDate = (isoString) => { if (!isoString) return ''; const d = new Date(isoString); if (Number.isNaN(d.getTime())) return ''; return d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short', }); }; const getMonthKey = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; const guessCategory = (title) => { if (!title) return 'Другое'; const t = title.toLowerCase(); if (/(super|market|магазин|магнит|кошелек|grocery|food|еда|dodo|kfc|burger)/.test(t)) return 'Еда'; if (/(аптека|pharm|apteka|drug)/.test(t)) return 'Здоровье'; if (/(taxi|такси|yandex go|bolt|uber|bus|metro)/.test(t)) return 'Транспорт'; if (/(аренда|rent|ipotek|ипотека|квартира)/.test(t)) return 'Жильё'; if (/(netflix|spotify|youtube|yandex plus|подписка|subscription)/.test(t)) return 'Подписки'; if (/(steam|psn|xbox|game|игр)/.test(t)) return 'Игры/развлечения'; if (/(cafe|кафе|coffee|кофе|restaurant|ресторан)/.test(t)) return 'Еда'; if (/(ozon|wildberries|wb|aliexpress)/.test(t)) return 'Покупки'; return 'Другое'; }; const sumBy = (arr, fn) => arr.reduce((acc, item, idx) => acc + (fn(item, idx) || 0), 0); const buildMonthlyTotals = (expenses) => { const totals = {}; expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); const key = getMonthKey(d); totals[key] = (totals[key] || 0) + (Number(e.amount) || 0); }); return totals; }; const buildCategoryTotalsForMonth = (expenses, monthKey) => { const m = {}; expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); if (getMonthKey(d) !== monthKey) return; const cat = e.category || 'Другое'; m[cat] = (m[cat] || 0) + (Number(e.amount) || 0); }); return m; }; const forecastNextMonth = (expenses) => { const totalsMap = buildMonthlyTotals(expenses); const keys = Object.keys(totalsMap).sort(); if (keys.length === 0) return 0; const ys = keys.map((k) => totalsMap[k]); if (keys.length === 1) return ys[0]; const n = ys.length; let sumX = 0; let sumY = 0; let sumXY = 0; let sumX2 = 0; for (let i = 0; i < n; i += 1) { const x = i; const y = ys[i]; sumX += x; sumY += y; sumXY += x * y; sumX2 += x * x; } const denom = n * sumX2 - sumX * sumX; const slope = denom === 0 ? 0 : (n * sumXY - sumX * sumY) / denom; const intercept = (sumY - slope * sumX) / n; const forecast = intercept + slope * n; return forecast > 0 ? forecast : ys[ys.length - 1]; }; const detectAnomalies = (expenses) => { if (!expenses.length) return []; const amounts = expenses.map((e) => Number(e.amount) || 0); const avg = sumBy(amounts, (a) => a) / amounts.length; const threshold = Math.max(avg * 2, 0); return expenses.filter((e) => (Number(e.amount) || 0) > threshold && threshold > 0); }; const calculateDailyStreak = (expenses, monthlyIncome) => { if (!expenses.length || !monthlyIncome) return 0; const dailyBudget = monthlyIncome / 30; if (dailyBudget <= 0) return 0; const byDay = {}; expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); const key = d.toISOString().substring(0, 10); byDay[key] = (byDay[key] || 0) + (Number(e.amount) || 0); }); const today = new Date(); let streak = 0; for (let i = 0; i < 365; i += 1) { const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000); const key = d.toISOString().substring(0, 10); const spent = byDay[key] || 0; if (spent <= dailyBudget) { streak += 1; } else { break; } } return streak; }; const countNoSpendDaysThisMonth = (expenses) => { if (!expenses.length) return 0; const now = new Date(); const year = now.getFullYear(); const month = now.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const spentDays = new Set(); expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); if (d.getFullYear() !== year || d.getMonth() !== month) return; spentDays.add(d.getDate()); }); return daysInMonth - spentDays.size; }; const calculateFinancialHealth = (userData) => { const expenses = userData.expenses || []; const settings = userData.settings || {}; const monthlyIncome = Number(settings.monthlyIncome) || Number(userData.income) || 0; const now = new Date(); const currentMonthKey = getMonthKey(now); const monthTotals = buildMonthlyTotals(expenses); const totalThisMonth = monthTotals[currentMonthKey] || 0; const savings = Math.max(0, monthlyIncome - totalThisMonth); const savingsRate = monthlyIncome > 0 ? savings / monthlyIncome : 0; const unnecessary = sumBy(expenses, (e) => { if (e.type === 'income') return 0; const d = new Date(e.date || new Date()); if (getMonthKey(d) !== currentMonthKey) return 0; const c = (e.category || '').toLowerCase(); if ( c.includes('игр') || c.includes('развлеч') || c.includes('подпис') || c.includes('покуп') || c.includes('кафе') || c.includes('bar') || c.includes('club') ) { return Number(e.amount) || 0; } return 0; }); const unnecessaryShare = monthlyIncome > 0 ? unnecessary / monthlyIncome : 0; let score = 50; score += Math.min(20, savingsRate * 40); score -= Math.min(15, unnecessaryShare * 30); if (userData.goals && userData.goals.length) score += 5; if (expenses.length > 20) score += 5; score = Math.max(0, Math.min(100, Math.round(score))); let label = 'Нужно больше данных'; if (score >= 80) { label = 'Отличное состояние'; } else if (score >= 60) { label = 'Хорошее состояние'; } else if (score >= 40) { label = 'Среднее, есть над чем поработать'; } else { label = 'Слабое, нужен план'; } return { score, label, monthlyIncome, totalThisMonth, savings, unnecessary, }; }; // простое шифрование заметок по PIN (XOR + hex, не для продакшена) const encryptNote = (plain, pin) => { if (!plain) return ''; if (!pin) return plain; const digits = pin.split('').map((d) => parseInt(d, 10) || 0); if (!digits.length) return plain; let out = ''; for (let i = 0; i < plain.length; i += 1) { const code = plain.charCodeAt(i) ^ digits[i % digits.length]; out += code.toString(16).padStart(2, '0'); } return out; }; const decryptNote = (cipher, pin) => { if (!cipher) return ''; if (!pin) return cipher; const digits = pin.split('').map((d) => parseInt(d, 10) || 0); if (!digits.length) return cipher; try { let out = ''; for (let i = 0; i < cipher.length; i += 2) { const hex = cipher.substr(i, 2); const code = parseInt(hex, 16) ^ digits[(i / 2) % digits.length]; out += String.fromCharCode(code); } return out; } catch (e) { return ''; } }; const buildSmartReminders = (userData) => { const reminders = []; const expenses = userData.expenses || []; const settings = userData.settings || {}; const now = new Date(); const nowKey = getMonthKey(now); const last7Total = sumBy(expenses, (e) => { if (e.type === 'income') return 0; const d = new Date(e.date || new Date()); const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24); if (diff >= 0 && diff <= 7) { return Number(e.amount) || 0; } return 0; }); const weeklyBudget = Number(settings.weeklyBudget) || 0; if (weeklyBudget > 0 && last7Total > weeklyBudget) { reminders.push( `За последние 7 дней вы уже вышли за недельный бюджет на ${formatCurrency( last7Total - weeklyBudget, settings.currency || DEFAULT_CURRENCY, )}.`, ); } else if (weeklyBudget > 0 && last7Total > weeklyBudget * 0.8) { reminders.push('Вы приблизились к недельному бюджету — самое время притормозить расходы.'); } (userData.goals || []).forEach((g) => { const target = Number(g.targetAmount) || 0; const saved = Number(g.savedAmount) || 0; if (!target) return; const d = g.deadline ? new Date(g.deadline) : null; const left = target - saved; if (left <= 0 || !d) return; const daysLeft = Math.round((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); if (daysLeft <= 0) return; const perDay = left / daysLeft; if (perDay > 0) { reminders.push( `До цели «${g.title}» осталось ${formatCurrency( left, settings.currency || DEFAULT_CURRENCY, )}. Нужно откладывать примерно ${formatCurrency( perDay, settings.currency || DEFAULT_CURRENCY, )} в день.`, ); } }); const monthTotals = buildMonthlyTotals(expenses); const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); const prevKey = getMonthKey(prevMonth); const totalNow = monthTotals[nowKey] || 0; const totalPrev = monthTotals[prevKey] || 0; if (totalPrev > 0 && totalNow > totalPrev * 1.15) { const diff = totalNow - totalPrev; reminders.push( `В этом месяце ваши траты уже на ${formatCurrency( diff, settings.currency || DEFAULT_CURRENCY, )} больше, чем в прошлом.`, ); } if (!reminders.length) { reminders.push('Вы молодец — критичных напоминаний сейчас нет 🎉'); } return reminders; }; const buildInsightsSummary = (userData) => { const expenses = userData.expenses || []; const settings = userData.settings || {}; const now = new Date(); const currentMonthKey = getMonthKey(now); const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); const prevKey = getMonthKey(prevMonth); const monthTotals = buildMonthlyTotals(expenses); const totalNow = monthTotals[currentMonthKey] || 0; const totalPrev = monthTotals[prevKey] || 0; const catNow = buildCategoryTotalsForMonth(expenses, currentMonthKey); const catPrev = buildCategoryTotalsForMonth(expenses, prevKey); const entriesNow = Object.entries(catNow); const topCat = entriesNow.sort((a, b) => b[1] - a[1])[0]; const currency = settings.currency || DEFAULT_CURRENCY; const anomalies = detectAnomalies(expenses).slice(0, 3); const lines = []; if (totalPrev > 0 && totalNow > 0) { const delta = totalNow - totalPrev; const pct = (delta / totalPrev) * 100; if (Math.abs(pct) >= 5) { if (pct > 0) { lines.push( `Общие расходы выросли на ${pct.toFixed(1)}% по сравнению с прошлым месяцем (${formatCurrency( totalNow, currency, )} против ${formatCurrency(totalPrev, currency)}).`, ); } else { lines.push( `Вы тратите на ${Math.abs(pct).toFixed( 1, )}% меньше, чем в прошлом месяце — отличный прогресс 👏`, ); } } } if (topCat) { lines.push( `Больше всего вы тратите на категорию «${topCat[0]}» — ${formatCurrency( topCat[1], currency, )} в этом месяце.`, ); } Object.keys(catNow).forEach((c) => { const nowVal = catNow[c]; const prevVal = catPrev[c] || 0; if (prevVal <= 0) return; const pct = (nowVal - prevVal) / prevVal; if (pct >= 0.25 && nowVal > 0) { lines.push( `Категория «${c}» выросла на ${Math.round( pct * 100, )}% относительно прошлого месяца — стоит проверить, можно ли там сэкономить.`, ); } }); if (anomalies.length) { const txt = anomalies .map((a) => `"${a.title}" на ${formatCurrency(a.amount, currency)}`) .join(', '); lines.push(`Обнаружены разовые крупные траты: ${txt}. Убедитесь, что они действительно нужны.`); } if (!lines.length) { lines.push('Пока недостаточно данных для глубокого анализа. Начните добавлять траты 🙂'); } return lines; }; const generateAiAnswer = (promptRaw, userData) => { const prompt = promptRaw.toLowerCase(); const health = calculateFinancialHealth(userData); const insights = buildInsightsSummary(userData); const currency = (userData.settings && userData.settings.currency) || DEFAULT_CURRENCY; const expenses = userData.expenses || []; const monthlyTotals = buildMonthlyTotals(expenses); const nowKey = getMonthKey(new Date()); const totalThisMonth = monthlyTotals[nowKey] || 0; if (!expenses.length && !prompt.includes('регистрац')) { return 'У меня пока нет ваших данных. Добавьте несколько трат и доходов — тогда я смогу давать персональные советы.'; } if (prompt.includes('сократить') || prompt.includes('эконом') || prompt.includes('сэкономить')) { const catNow = buildCategoryTotalsForMonth(expenses, nowKey); const entries = Object.entries(catNow).sort((a, b) => b[1] - a[1]); const top3 = entries.slice(0, 3); const tips = top3 .map( ([c, v]) => `• «${c}» — ${formatCurrency( v, currency, )} в месяц. Попробуйте снизить хотя бы на 10–20%.`, ) .join('\n'); return ( `Сейчас ваши траты за месяц: ${formatCurrency( totalThisMonth, currency, )}. Чтобы сократить их, лучше всего начать с самых крупных категорий:\n\n${tips}\n\n` + 'Дополнительно:\n' + '• установите недельный бюджет и фиксируйте все траты;\n' + '• избегайте спонтанных покупок — делайте «паузу 24 часа»;\n' + '• для подписок — оставьте только то, чем пользуетесь каждую неделю.' ); } if (prompt.includes('как распределить') || prompt.includes('зарплат')) { const match = promptRaw.match(/(\d[\d\s]{2,})/); const income = match ? parseInt(match[1].replace(/\s/g, ''), 10) : health.monthlyIncome || 0; if (!income) { return 'Напишите, пожалуйста, размер зарплаты (например: «Как распределить зарплату 300 000?»), или задайте месячный доход в настройках. Тогда я предложу детальный план распределения.'; } const needs = income * 0.5; const wants = income * 0.3; const saving = income * 0.2; return ( `Классическая модель 50/30/20 для зарплаты ${formatCurrency(income, currency)}:\n\n` + `• 50% (${formatCurrency(needs, currency)}) — обязательные расходы (жильё, еда, транспорт, кредиты);\n` + `• 30% (${formatCurrency(wants, currency)}) — «хотелки» и развлечения;\n` + `• 20% (${formatCurrency(saving, currency)}) — сбережения и цели.\n\n` + 'Вы можете адаптировать: если есть кредиты — уменьшите «хотелки» до 10–15% и направьте разницу на досрочное погашение и накопления.' ); } if (prompt.includes('стоит ли покупать') || prompt.includes('покупать')) { return ( 'Задайте себе три вопроса перед покупкой:\n' + '1) Нужна ли вещь прямо сейчас или это импульс?\n' + '2) Сколько часов вашей работы она стоит?\n' + '3) Через месяц вы будете так же рады этой покупке?\n\n' + 'Если сумма покупки >10% от месячного дохода — хорошо бы выдержать «паузу 24 часа». ' + 'Также посмотрите, не мешает ли эта покупка вашим финансовым целям (накоплениям и т.п.).' ); } if (prompt.includes('что если') || prompt.includes('если буду')) { return ( 'Функция «Что если?» уже доступна на вкладке «Цели» — там можно смоделировать, что будет, если сократить траты на кафе или начать откладывать больше. ' + 'Если хотите, сформулируйте сценарий конкретно (например: «Что будет, если каждый месяц откладывать на 20 000 больше?»), и я прикину эффект в чате.' ); } const baseInsights = insights.slice(0, 2).join('\n\n'); return ( `Краткий обзор вашего финансового состояния:\n` + `• Индекс финансового здоровья: ${health.score}/100 (${health.label}).\n` + `• Траты в этом месяце: ${formatCurrency(totalThisMonth, currency)}.\n` + `• Потенциальные сбережения: ${formatCurrency(health.savings, currency)}.\n\n` + `${baseInsights}\n\n` + 'Задавайте мне конкретные вопросы: например, «Как сократить расходы на 20%?» или ' + '«Помоги перераспределить бюджет с учётом цели на 800 000».' ); }; // helper для дефолтных данных пользователя const createDefaultUserData = (themeMode, profile = {}) => ({ income: 0, expenses: [], goals: [], subscriptions: [], settings: { monthlyIncome: 0, weeklyBudget: 0, currency: DEFAULT_CURRENCY, antiImpulseEnabled: false, antiImpulseThreshold: 0, themeMode: themeMode || 'dark', }, profile, secureNote: '', }); // helper для выбора картинки из галереи const pickImageFromLibrary = async () => { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { Alert.alert('Нет доступа', 'Разрешите доступ к фото в настройках устройства.'); return null; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8, }); if (result.canceled) return null; const asset = result.assets && result.assets[0]; if (!asset) return null; return asset.uri; }; // ---------- OCR + ИИ-проверка чеков ---------- // ⚠️ Snack / Expo Go не поддерживает expo-mlkit-ocr из коробки. // Поэтому здесь заглушка, чтобы код работал в онлайн-Snack. // В реальном приложении на EAS dev-client можно вернуть оригинальный код с ML Kit. const runOcrOnReceiptImage = async (uri) => { console.log('runOcrOnReceiptImage: stub on Snack, uri:', uri); if (Platform.OS === 'web') { Alert.alert( 'OCR недоступен', 'Распознавание чеков не поддерживается в веб-версии Snack. Введите сумму вручную.', ); } else { Alert.alert( 'OCR недоступен в Snack', 'Для сканирования чеков через ML Kit нужен кастомный dev-клиент. В демо на Snack сумму нужно вводить вручную.', ); } // Возвращаем текст, чтобы показать превью, но сумма из него не извлечётся return '(OCR недоступен в Snack — введите сумму вручную)'; }; // «Умный» парсер текста чека: пытается понять именно итоговую сумму const parseReceiptOcrText = (text) => { if (!text) return null; const lines = text .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); if (!lines.length) return null; // Денежный паттерн: 1 234,56 / 1234.56 / 12 345 / 12345 и т.п. const MONEY_REGEX = /\d{1,3}(?:[ \u00A0]\d{3})*(?:[.,]\d{1,2})?|\d+[.,]\d{1,2}/g; const parseMoney = (raw) => { if (!raw) return NaN; let cleaned = String(raw) .replace(/\u00A0/g, ' ') .replace(/[^\d.,\s]/g, '') .replace(/\s+/g, ' ') .trim(); if (!cleaned) return NaN; cleaned = cleaned.replace(/\s/g, ''); if (cleaned.includes(',') && cleaned.includes('.')) { const lastComma = cleaned.lastIndexOf(','); const lastDot = cleaned.lastIndexOf('.'); if (lastComma > lastDot) { cleaned = cleaned.replace(/\./g, '').replace(',', '.'); } else { cleaned = cleaned.replace(/,/g, ''); } } else if (cleaned.includes(',')) { cleaned = cleaned.replace(',', '.'); } const value = parseFloat(cleaned); return Number.isFinite(value) ? value : NaN; }; const isTotalLine = (line) => { const lower = line.toLowerCase().replace(/\s+/g, ' '); return ( /итог(о)?/.test(lower) || /к оплате/.test(lower) || /сумма к оплате/.test(lower) || /итог по чеку/.test(lower) || /total/.test(lower) ); }; let amount = null; // 1) Ищем строку с ИТОГ / К ОПЛАТЕ и берём сумму оттуда for (const line of lines) { if (!isTotalLine(line)) continue; const matches = line.match(MONEY_REGEX); if (matches && matches.length) { const raw = matches[matches.length - 1]; const v = parseMoney(raw); if (Number.isFinite(v) && v > 0) { amount = v; break; } } } // 2) Если не нашли — смотрим последние 3–5 строк if (amount == null) { const tail = lines.slice(-5); const candidates = []; tail.forEach((line) => { const matches = line.match(MONEY_REGEX); if (!matches) return; matches.forEach((m) => { const v = parseMoney(m); if (Number.isFinite(v) && v > 0 && v < 1e7) { candidates.push(v); } }); }); if (candidates.length) { amount = Math.max(...candidates); } } // 3) Совсем fallback — ищем по всему тексту if (amount == null) { const candidates = []; lines.forEach((line) => { const matches = line.match(MONEY_REGEX); if (!matches) return; matches.forEach((m) => { const v = parseMoney(m); if (Number.isFinite(v) && v > 0 && v < 1e7) { candidates.push(v); } }); }); if (candidates.length) { amount = Math.max(...candidates); } } // 4) Название магазина / покупки let titleLine = ''; for (const line of lines) { const letters = line.replace(/[^A-Za-zА-Яа-яЁё]/g, ''); if (!letters.length) continue; if (isTotalLine(line)) continue; if (/кассир|смена|чек|фискальн|ккм|рнк|зн/i.test(line)) continue; titleLine = line; break; } if (!titleLine) { titleLine = 'Покупка по чеку'; } const category = guessCategory(titleLine); return { title: titleLine, amount: Number.isFinite(amount) && amount > 0 ? amount : 0, category, }; }; // ---------- небольшие компоненты ---------- const CategoryBarChart = ({ data, theme }) => { if (!data || !data.length) return null; const max = Math.max(...data.map((d) => d.value || 0)); if (!max) return null; return ( <View style={base.chartRow}> {data.map((item) => { const pct = (item.value / max) * 100; return ( <View key={item.label} style={{ marginBottom: 6 }}> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted }]}>{item.label}</Text> <Text style={[base.tinyText, { color: theme.text }]}> {formatCurrency(item.value)} </Text> </View> <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 3 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accent, width: `${pct}%`, }, ]} /> </View> </View> ); })} </View> ); }; const SimpleMonthlyChart = ({ expenses, theme }) => { const totalsMap = buildMonthlyTotals(expenses || []); const keys = Object.keys(totalsMap).sort(); if (!keys.length) return null; const items = keys.slice(-6).map((k) => ({ key: k, value: totalsMap[k] })); const max = Math.max(...items.map((i) => i.value)); if (!max) return null; return ( <View style={{ marginTop: 4 }}> {items.map((item) => { const [year, month] = item.key.split('-'); const label = `${month}.${year.slice(-2)}`; const pct = (item.value / max) * 100; return ( <View key={item.key} style={{ marginBottom: 4 }}> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted }]}>{label}</Text> <Text style={[base.tinyText, { color: theme.text }]}> {formatCurrency(item.value)} </Text> </View> <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 3 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accentSoft, width: `${pct}%`, }, ]} /> </View> </View> ); })} </View> ); }; const SpendingGauge = ({ monthlyIncome, spent, theme, currency }) => { if (!monthlyIncome) return null; const ratio = monthlyIncome > 0 ? spent / monthlyIncome : 0; const pct = Math.max(0, Math.min(1, ratio)); const labelPct = (pct * 100).toFixed(0); return ( <View style={{ marginTop: 10 }}> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Расходы от дохода</Text> <Text style={[base.tinyText, { color: theme.text }]}> {labelPct}% ({formatCurrency(spent, currency)}) </Text> </View> <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 4, height: 12 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: ratio > 1 ? theme.danger : theme.accent, width: `${pct * 100}%`, }, ]} /> </View> {ratio > 1 && ( <Text style={[base.tinyText, { color: theme.danger, marginTop: 4 }]}> Вы вышли за рамки месячного дохода — стоит пересмотреть план. </Text> )} </View> ); }; // ---------- экраны ---------- const AuthScreen = ({ users, onLogin, onRegister, theme }) => { const [mode, setMode] = useState('login'); // 'login' | 'register' const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [pin, setPin] = useState(''); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [phone, setPhone] = useState(''); const [birthDate, setBirthDate] = useState(''); const [avatarUri, setAvatarUri] = useState(''); const [error, setError] = useState(''); const toggleMode = () => { setError(''); setMode((m) => (m === 'login' ? 'register' : 'login')); }; const handleLoginPress = () => { setError(''); const normalizedEmail = email.trim().toLowerCase(); if (!normalizedEmail || !password) { setError('Заполните email и пароль.'); return; } const user = users.find((u) => u.email === normalizedEmail); if (!user || user.password !== password) { setError('Неверный email или пароль.'); return; } onLogin(user); }; const handleRegisterPress = () => { setError(''); const normalizedEmail = email.trim().toLowerCase(); if (!normalizedEmail || !password) { setError('Заполните email и пароль.'); return; } if (!normalizedEmail.includes('@')) { setError('Некорректный email.'); return; } if (password.length < 4) { setError('Пароль должен быть не короче 4 символов.'); return; } if (!firstName.trim()) { setError('Укажите имя.'); return; } if (!phone.trim()) { setError('Укажите номер телефона.'); return; } if (users.some((u) => u.email === normalizedEmail)) { setError('Пользователь с таким email уже существует.'); return; } const newUser = { id: `u_${Date.now()}`, email: normalizedEmail, password, pin: pin || '', createdAt: new Date().toISOString(), }; const profile = { firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), birthDate: birthDate.trim(), avatarUri: avatarUri || '', }; onRegister(newUser, profile); }; const handlePickAvatar = async () => { const uri = await pickImageFromLibrary(); if (uri) { setAvatarUri(uri); } }; return ( <SafeAreaView style={[ base.container, { backgroundColor: theme.background }, ]} > <StatusBar barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'} /> <KeyboardAvoidingView style={base.flex1} behavior={Platform.OS === 'ios' ? 'padding' : undefined} > <View style={[base.authContainer]}> <View style={{ marginBottom: 32 }}> <Text style={[base.title, { color: theme.text }]}> Финансовый ИИ-помощник </Text> <Text style={[base.subtitle, { color: theme.textMuted }]}> Управляй доходами, расходами, целями и сканируй чеки с ИИ. </Text> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[ base.cardTitle, { color: theme.text }, ]} > {mode === 'login' ? 'Вход' : 'Регистрация'} </Text> <View style={[ base.pill, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.pillText, { color: theme.accent }, ]} > ⚡ ИИ + чек-сканер </Text> </View> </View> {error ? ( <Text style={[base.errorText, { color: theme.danger }]}>{error}</Text> ) : null} {mode === 'register' && ( <> <View style={base.row}> <TouchableOpacity onPress={handlePickAvatar}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> + </Text> </View> )} </TouchableOpacity> <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото профиля (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте аватар, чтобы приложение стало более личным. </Text> </View> </View> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} keyboardType="phone-pad" value={phone} onChangeText={setPhone} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </> )} <TextInput placeholder="Email" placeholderTextColor={theme.textMuted} autoCapitalize="none" keyboardType="email-address" value={email} onChangeText={setEmail} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Пароль" placeholderTextColor={theme.textMuted} secureTextEntry value={password} onChangeText={setPassword} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> {mode === 'register' && ( <TextInput placeholder="PIN (необязательно, для шифрования заметок)" placeholderTextColor={theme.textMuted} value={pin} onChangeText={setPin} keyboardType="number-pad" maxLength={6} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> )} <TouchableOpacity onPress={mode === 'login' ? handleLoginPress : handleRegisterPress} style={[ base.button, { backgroundColor: theme.accent, marginTop: 8 }, ]} > <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {mode === 'login' ? 'Войти' : 'Зарегистрироваться'} </Text> </TouchableOpacity> <TouchableOpacity onPress={toggleMode}> <Text style={[ base.link, { color: theme.textMuted }, ]} > {mode === 'login' ? 'Нет аккаунта? Создать' : 'Уже есть аккаунт? Войти'} </Text> </TouchableOpacity> </View> <View style={{ marginTop: 24 }}> <Text style={[ base.smallText, { color: theme.textMuted }, ]} > Данные хранятся только локально на устройстве (AsyncStorage). Для реального приложения стоит добавить серверный бэкап и более серьёзную криптографию. </Text> </View> </View> </KeyboardAvoidingView> </SafeAreaView> ); }; const HomeScreen = ({ userData, theme }) => { const health = calculateFinancialHealth(userData); const insights = buildInsightsSummary(userData); const reminders = buildSmartReminders(userData); const expenses = userData.expenses || []; const settings = userData.settings || {}; const currency = settings.currency || DEFAULT_CURRENCY; const now = new Date(); const currentMonthKey = getMonthKey(now); const catNowMap = buildCategoryTotalsForMonth(expenses, currentMonthKey); const categoryData = Object.entries(catNowMap) .map(([label, value]) => ({ label, value })) .sort((a, b) => b.value - a.value); const forecast = forecastNextMonth(expenses); const streak = calculateDailyStreak( expenses, Number(settings.monthlyIncome) || Number(userData.income) || 0, ); const noSpendDays = countNoSpendDaysThisMonth(expenses); const profile = userData.profile || {}; const firstName = profile.firstName || ''; const badges = []; if (health.score >= 80) badges.push('Экономист месяца'); if (streak >= 7) badges.push('7 дней в плюсе'); if (noSpendDays >= 5) badges.push('Дни без трат'); if (expenses.length >= 50) badges.push('Финансовый самурай'); return ( <ScrollView style={[ base.content, { backgroundColor: theme.background }, ]} showsVerticalScrollIndicator={false} > <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.rowBetween}> <View style={{ flex: 1 }}> <Text style={[base.cardSubtitle, { color: theme.textMuted }]}> Индекс финансового здоровья </Text> <Text style={[ { fontSize: 32, fontWeight: '700', marginTop: 4 }, { color: theme.text }, ]} > {health.score} <Text style={{ fontSize: 16 }}>/100</Text> </Text> {firstName ? ( <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 4 }, ]} > {firstName}, это ваш персональный индекс. </Text> ) : null} </View> <View style={[ { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 16, }, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.tinyText, { color: theme.textMuted }]}>Статус</Text> <Text style={[ base.smallText, { color: theme.text, fontWeight: '600', marginTop: 2 }, ]} > {health.label} </Text> </View> </View> <View style={{ marginTop: 10 }}> <View style={base.rowBetween}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Доход / мес.</Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {health.monthlyIncome ? formatCurrency(health.monthlyIncome, currency) : 'Не задан'} </Text> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Траты в этом месяце </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(health.totalThisMonth, currency)} </Text> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Сбережения</Text> <Text style={[ base.smallText, { color: health.savings >= 0 ? theme.success : theme.danger, marginTop: 2, }, ]} > {formatCurrency(health.savings, currency)} </Text> </View> <SpendingGauge monthlyIncome={health.monthlyIncome} spent={health.totalThisMonth} theme={theme} currency={currency} /> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Привычки</Text> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Серия дней в рамках бюджета: {streak} </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Дней без трат в этом месяце: {noSpendDays} </Text> </View> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Бейджи</Text> <View style={[base.row, { flexWrap: 'wrap', marginTop: 4 }]}> {badges.length ? ( badges.map((b) => ( <View key={b} style={[ base.badge, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.tinyText, { color: theme.accent }, ]} > ✨ {b} </Text> </View> )) ) : ( <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Собирайте награды, держась в рамках бюджета. </Text> )} </View> </View> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>AI-инсайты</Text> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>🧩 Анализ</Text> </View> </View> {insights.slice(0, 3).map((line, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{line}</Text> </View> ))} </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Категории</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Текущий месяц </Text> </View> <CategoryBarChart data={categoryData} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Динамика</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Прогноз: {formatCurrency(forecast, currency)} </Text> </View> <SimpleMonthlyChart expenses={expenses} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Умные напоминания</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔔</Text> </View> {reminders.map((r, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{r}</Text> </View> ))} </View> </ScrollView> ); }; const ExpensesScreen = ({ userData, onUpdateUserData, theme }) => { const [title, setTitle] = useState(''); const [amount, setAmount] = useState(''); const [category, setCategory] = useState(''); const [isIncome, setIsIncome] = useState(false); const [isScanning, setIsScanning] = useState(false); const [lastReceiptPreview, setLastReceiptPreview] = useState(null); // { uri, text } const expenses = [...(userData.expenses || [])].sort( (a, b) => new Date(b.date || 0).getTime() - new Date(a.date || 0).getTime(), ); const settings = userData.settings || {}; const currency = settings.currency || DEFAULT_CURRENCY; const antiImpulseEnabled = settings.antiImpulseEnabled; const antiImpulseThreshold = Number(settings.antiImpulseThreshold) || 0; const addEntry = (n, guessed, customTitle, extraMeta) => { const entry = { id: `exp_${Date.now()}`, title: (customTitle || title).trim(), amount: n, category: guessed, type: isIncome ? 'income' : 'expense', date: new Date().toISOString(), ...(extraMeta ? { meta: extraMeta } : {}), }; onUpdateUserData((prev) => ({ ...prev, expenses: [...(prev.expenses || []), entry], })); setTitle(''); setAmount(''); setCategory(''); setIsIncome(false); }; const handleAdd = () => { const n = parseFloat(String(amount).replace(',', '.')); if (!title || !Number.isFinite(n) || n <= 0) { Alert.alert('Ошибка', 'Введите название и сумму.'); return; } const guessed = category || guessCategory(title); if (!isIncome && antiImpulseEnabled && antiImpulseThreshold > 0 && n >= antiImpulseThreshold) { Alert.alert( 'Анти-импульсная защита', `Покупка на ${formatCurrency( n, currency, )} превышает ваш лимит в ${formatCurrency(antiImpulseThreshold, currency)}.\n\n` + 'Вы уверены, что хотите продолжить?', [ { text: 'Отменить', style: 'cancel' }, { text: 'Купить', style: 'destructive', onPress: () => addEntry(n, guessed), }, ], ); return; } addEntry(n, guessed); }; const handleScanReceipt = async () => { try { setIsScanning(true); const perm = await ImagePicker.requestCameraPermissionsAsync(); if (!perm.granted) { Alert.alert('Нет доступа', 'Разрешите доступ к камере в настройках устройства.'); return; } const result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8, }); if (result.canceled) return; const asset = result.assets && result.assets[0]; if (!asset) return; const uri = asset.uri; const ocrText = await runOcrOnReceiptImage(uri); setLastReceiptPreview({ uri, text: ocrText }); const parsed = parseReceiptOcrText(ocrText); if (!parsed || !parsed.amount || parsed.amount <= 0) { Alert.alert( 'Не удалось распознать чек', 'На Snack распознавание суммы недоступно. Введите сумму вручную в блоке «Новая запись».', ); return; } const guessedCategory = parsed.category || guessCategory(parsed.title); const n = parsed.amount; // Авто-добавление в расходы onUpdateUserData((prev) => ({ ...prev, expenses: [ ...(prev.expenses || []), { id: `exp_receipt_${Date.now()}`, title: parsed.title || 'Покупка по чеку', amount: n, category: guessedCategory, type: 'expense', date: new Date().toISOString(), meta: { source: 'receipt', ocrText, imageUri: uri, }, }, ], })); Alert.alert( 'Чек добавлен', `Мы распознали сумму ${formatCurrency( n, currency, )} и категорию «${guessedCategory}».\nЗапись добавлена в историю расходов.`, ); } catch (e) { console.warn('Receipt scan error', e); Alert.alert('Ошибка', 'Не удалось обработать чек. Попробуйте ещё раз.'); } finally { setIsScanning(false); } }; const totalExpenses = sumBy(expenses, (e) => e.type === 'income' ? 0 : Number(e.amount) || 0, ); const totalIncome = sumBy(expenses, (e) => e.type === 'income' ? Number(e.amount) || 0 : 0, ); return ( <View style={[ base.content, { backgroundColor: theme.background }, ]} > <ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > {/* ИИ-проверка чеков */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <View style={{ flex: 1, paddingRight: 6 }}> <Text style={[base.cardTitle, { color: theme.text }]}> ИИ-проверка чеков </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 2 }]}> Сделайте фото чека — в полноценном приложении сумма будет распознана автоматически. В демо на Snack сумму нужно вводить вручную. </Text> </View> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>🤖 Чек-сканер</Text> </View> </View> <TouchableOpacity onPress={handleScanReceipt} disabled={isScanning} style={[ base.button, { backgroundColor: isScanning ? theme.cardAlt : theme.accent, marginTop: 8, flexDirection: 'row', }, ]} > {isScanning && ( <ActivityIndicator size="small" style={{ marginRight: 8 }} color="#FFFFFF" /> )} <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {isScanning ? 'Сканируем чек...' : 'Сканировать чек с ИИ'} </Text> </TouchableOpacity> {lastReceiptPreview && ( <View style={[base.row, { marginTop: 10 }]}> <Image source={{ uri: lastReceiptPreview.uri }} style={{ width: 56, height: 56, borderRadius: 12, marginRight: 8 }} /> <View style={{ flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Последний чек (OCR) </Text> <Text numberOfLines={3} style={[base.smallText, { color: theme.text, marginTop: 2 }]} > {lastReceiptPreview.text || 'Текст не распознан'} </Text> </View> </View> )} </View> {/* Ручное добавление записи */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Новая запись</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}> Доход </Text> <Switch value={isIncome} onValueChange={setIsIncome} thumbColor={isIncome ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Магазин / описание" placeholderTextColor={theme.textMuted} value={title} onChangeText={(text) => { setTitle(text); if (!category) { setCategory(guessCategory(text)); } }} style={[base.inputInline, { color: theme.text }]} /> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} keyboardType="numeric" value={amount} onChangeText={setAmount} style={[base.inputInline, { color: theme.text }]} /> <Text style={[base.smallText, { color: theme.textMuted }]}>{currency}</Text> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Категория</Text> <ScrollView horizontal showsHorizontalScrollIndicator={false}> {DEFAULT_CATEGORIES.map((c) => ( <TouchableOpacity key={c} onPress={() => setCategory(c)} style={[ base.tag, { backgroundColor: category === c ? theme.accentSoft : theme.cardAlt, borderWidth: 1, borderColor: category === c ? theme.accent : theme.border, }, ]} > <Text style={[ base.tagText, { color: category === c ? theme.accent : theme.textMuted, }, ]} > {c} </Text> </TouchableOpacity> ))} </ScrollView> </View> <TouchableOpacity onPress={handleAdd} style={[ base.button, { backgroundColor: theme.accent, marginTop: 12 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить </Text> </TouchableOpacity> </View> {/* Итоги */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.rowBetween}> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего расходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalExpenses, currency)} </Text> </View> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего доходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalIncome, currency)} </Text> </View> </View> </View> {/* История */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>История</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> {expenses.length} записей </Text> </View> {expenses.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Добавьте первую трату или доход. </Text> ) : ( <FlatList data={expenses} keyExtractor={(item) => item.id} scrollEnabled={false} renderItem={({ item }) => { const isImpulseHighlight = item.type !== 'income' && antiImpulseEnabled && antiImpulseThreshold > 0 && Number(item.amount) >= antiImpulseThreshold; const isReceipt = item.meta && item.meta.source === 'receipt'; return ( <View> <View style={base.expenseItem}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> {item.title} </Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted }]}> {formatDate(item.date)} </Text> <View style={[ base.tag, { backgroundColor: theme.cardAlt, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.textMuted }, ]} > {item.category || 'Другое'} </Text> </View> {isReceipt && ( <View style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > ИИ-чек </Text> </View> )} </View> </View> <Text style={[ base.smallText, { color: item.type === 'income' ? theme.success : isImpulseHighlight ? theme.danger : theme.text, }, ]} > {item.type === 'income' ? '+' : '-'} {formatCurrency(item.amount, currency)} </Text> </View> <View style={[ base.divider, { backgroundColor: theme.border }, ]} /> </View> ); }} /> )} </View> </ScrollView> </View> ); }; const GoalsScreen = ({ userData, onUpdateUserData, theme }) => { const [title, setTitle] = useState(''); const [target, setTarget] = useState(''); const [months, setMonths] = useState(''); const [whatIfCafe, setWhatIfCafe] = useState('30'); const [whatIfSave, setWhatIfSave] = useState('10000'); const [goalImage, setGoalImage] = useState(null); const [editGoalId, setEditGoalId] = useState(null); const [editTitle, setEditTitle] = useState(''); const [editTarget, setEditTarget] = useState(''); const [editDeadlineMonths, setEditDeadlineMonths] = useState(''); const goals = userData.goals || []; const settings = userData.settings || {}; const currency = settings.currency || DEFAULT_CURRENCY; const handleAddGoal = () => { const t = parseFloat(String(target).replace(',', '.')); const m = parseInt(months, 10); if (!title || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) { Alert.alert('Ошибка', 'Укажите цель, сумму и срок в месяцах.'); return; } const deadline = new Date(); deadline.setMonth(deadline.getMonth() + m); const newGoal = { id: `goal_${Date.now()}`, title: title.trim(), targetAmount: t, savedAmount: 0, deadline: deadline.toISOString(), imageUri: goalImage || null, }; onUpdateUserData((prev) => ({ ...prev, goals: [...(prev.goals || []), newGoal], })); setTitle(''); setTarget(''); setMonths(''); setGoalImage(null); }; const handleDeposit = (goalId, amount) => { const n = amount || 0; onUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).map((g) => g.id === goalId ? { ...g, savedAmount: (Number(g.savedAmount) || 0) + n } : g, ), expenses: [ ...(prev.expenses || []), { id: `save_${Date.now()}`, title: 'Перевод в копилку', amount: n, category: 'Сбережения', type: 'expense', date: new Date().toISOString(), }, ], })); }; const handlePickGoalImage = async () => { const uri = await pickImageFromLibrary(); if (uri) setGoalImage(uri); }; const handleEditGoalPress = (goal) => { const now = new Date(); const deadline = goal.deadline ? new Date(goal.deadline) : null; const monthsLeft = deadline && !Number.isNaN(deadline.getTime()) ? Math.max( 1, Math.round( (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30), ), ) : ''; setEditGoalId(goal.id); setEditTitle(goal.title); setEditTarget(String(goal.targetAmount || '')); setEditDeadlineMonths(monthsLeft ? String(monthsLeft) : ''); }; const handleSaveEditedGoal = () => { if (!editGoalId) return; const t = parseFloat(String(editTarget).replace(',', '.')); const m = parseInt(editDeadlineMonths, 10); if (!editTitle || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) { Alert.alert('Ошибка', 'Укажите корректные данные для цели.'); return; } const newDeadline = new Date(); newDeadline.setMonth(newDeadline.getMonth() + m); onUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).map((g) => g.id === editGoalId ? { ...g, title: editTitle.trim(), targetAmount: t, deadline: newDeadline.toISOString(), } : g, ), })); setEditGoalId(null); setEditTitle(''); setEditTarget(''); setEditDeadlineMonths(''); }; const handleCancelEdit = () => { setEditGoalId(null); setEditTitle(''); setEditTarget(''); setEditDeadlineMonths(''); }; const handleDeleteGoal = (goalId) => { Alert.alert( 'Удалить цель', 'Вы уверены, что хотите удалить эту цель? Прогресс по ней будет потерян.', [ { text: 'Отмена', style: 'cancel' }, { text: 'Удалить', style: 'destructive', onPress: () => { onUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).filter((g) => g.id !== goalId), })); }, }, ], ); }; const now = new Date(); const totalSavedAll = sumBy(goals, (g) => Number(g.savedAmount) || 0); const totalTargetAll = sumBy(goals, (g) => Number(g.targetAmount) || 0); const cafeReduce = parseFloat(String(whatIfCafe).replace(',', '.')) || 0; const extraWeekly = parseFloat(String(whatIfSave).replace(',', '.')) || 0; const cafeCurrent = sumBy(userData.expenses || [], (e) => { if (e.type === 'income') return 0; const c = (e.category || '').toLowerCase(); if (c.includes('еда') || c.includes('кафе')) { const d = new Date(e.date || new Date()); const key = getMonthKey(d); if (key === getMonthKey(now)) { return Number(e.amount) || 0; } } return 0; }); const cafePotential = cafeCurrent * (cafeReduce / 100); const whatIfMonthlyExtra = cafePotential + extraWeekly * 4; return ( <ScrollView style={[ base.content, { backgroundColor: theme.background }, ]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" > {/* Блок создания цели */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Цели и копилки</Text> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>💎 Планы</Text> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего накоплено: {formatCurrency(totalSavedAll, currency)} из{' '} {formatCurrency(totalTargetAll || 0, currency)} </Text> <View style={{ marginTop: 12 }}> <TouchableOpacity onPress={handlePickGoalImage} style={[ base.row, { marginBottom: 8, }, ]} > {goalImage ? ( <Image source={{ uri: goalImage }} style={[base.goalImage, { width: 80, height: 80 }]} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}>📷</Text> </View> )} <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото цели (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте визуал — так легче держаться плана. </Text> </View> </TouchableOpacity> <TextInput placeholder="Название цели (например, «Ноутбук»)" placeholderTextColor={theme.textMuted} value={title} onChangeText={setTitle} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={target} onChangeText={setTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={months} onChangeText={setMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <TouchableOpacity onPress={handleAddGoal} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить цель </Text> </TouchableOpacity> </View> </View> {/* Список целей с редактированием и удалением */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Мои цели</Text> </View> {goals.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Создайте первую цель, чтобы следить за прогрессом. </Text> ) : ( goals.map((g) => { const targetAmount = Number(g.targetAmount) || 0; const savedAmount = Number(g.savedAmount) || 0; const pct = targetAmount ? Math.min(100, (savedAmount / targetAmount) * 100) : 0; const deadline = g.deadline ? new Date(g.deadline) : null; const daysLeft = deadline && !Number.isNaN(deadline.getTime()) ? Math.round( (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), ) : null; const left = Math.max(0, targetAmount - savedAmount); const perWeek = daysLeft && daysLeft > 0 ? (left / daysLeft) * 7 : left / 4 || 0; const isEditing = editGoalId === g.id; return ( <View key={g.id} style={{ marginBottom: 12 }}> <View style={base.rowBetween}> <Text style={[base.smallText, { color: theme.text, maxWidth: '70%' }]}> {g.title} </Text> <View style={base.row}> <TouchableOpacity onPress={() => handleEditGoalPress(g)}> <Text style={[base.tinyText, { color: theme.accent, marginRight: 12 }]}> Изм. </Text> </TouchableOpacity> <TouchableOpacity onPress={() => handleDeleteGoal(g.id)}> <Text style={[base.tinyText, { color: theme.danger }]}> Удалить </Text> </TouchableOpacity> </View> </View> {g.imageUri ? ( <Image source={{ uri: g.imageUri }} style={base.goalImage} /> ) : null} <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accent, width: `${pct}%`, }, ]} /> </View> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> {formatCurrency(savedAmount, currency)} из{' '} {formatCurrency(targetAmount, currency)} </Text> {daysLeft !== null && ( <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 4 }, ]} > Осталось ~{daysLeft} дн. </Text> )} </View> {left > 0 && ( <View style={base.rowBetween}> <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 2 }, ]} > Чтобы успеть: ~{formatCurrency(perWeek, currency)} в неделю </Text> <TouchableOpacity onPress={() => handleDeposit(g.id, 10000)} style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 2, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > +10 000 в копилку </Text> </TouchableOpacity> </View> )} {isEditing && ( <View style={{ marginTop: 8, padding: 10, borderRadius: 12, backgroundColor: theme.cardAlt, }} > <Text style={[base.tinyText, { color: theme.textMuted, marginBottom: 4 }]}> Редактирование цели </Text> <TextInput placeholder="Название цели" placeholderTextColor={theme.textMuted} value={editTitle} onChangeText={setEditTitle} style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={editTarget} onChangeText={setEditTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={editDeadlineMonths} onChangeText={setEditDeadlineMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={base.rowBetween}> <TouchableOpacity onPress={handleSaveEditedGoal} style={[ base.button, { flex: 1, backgroundColor: theme.accent, marginTop: 4, marginRight: 4, }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить </Text> </TouchableOpacity> <TouchableOpacity onPress={handleCancelEdit} style={[ base.button, { flex: 1, backgroundColor: theme.card, marginTop: 4, marginLeft: 4, borderWidth: 1, borderColor: theme.border, }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Отмена </Text> </TouchableOpacity> </View> </View> )} <View style={[ base.divider, { backgroundColor: theme.border, marginTop: 8 }, ]} /> </View> ); }) )} </View> {/* Блок «Что если?» */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>«Что если?»</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧪 Симулятор</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Смоделируйте изменения привычек и посмотрите влияние на накопления. </Text> <View style={{ marginTop: 8 }}> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> На сколько % сократить кафе/еду? </Text> <TextInput placeholder="30" placeholderTextColor={theme.textMuted} value={whatIfCafe} onChangeText={setWhatIfCafe} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Доп. отложить в неделю </Text> <TextInput placeholder="10 000" placeholderTextColor={theme.textMuted} value={whatIfSave} onChangeText={setWhatIfSave} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.smallText, { color: theme.text }]}> При таком сценарии вы сможете дополнительно накопить около{' '} {formatCurrency(whatIfMonthlyExtra, currency)} в месяц. </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> За год это будет примерно{' '} {formatCurrency(whatIfMonthlyExtra * 12, currency)} — почти целая отдельная цель 💡 </Text> </View> </View> </View> </ScrollView> ); }; const AiChatScreen = ({ userData, theme }) => { const [messages, setMessages] = useState([ { id: 'm_hello', from: 'ai', text: 'Привет! Я твой финансовый ИИ-ассистент. Задавай вопросы: «Как сократить расходы на 20%?», «Как распределить зарплату?», «Что будет, если начать откладывать больше?»', }, ]); const [input, setInput] = useState(''); const [voiceOn, setVoiceOn] = useState(false); const sendText = (text) => { const trimmed = text.trim(); if (!trimmed) return; const userMsg = { id: `m_${Date.now()}_u`, from: 'user', text: trimmed, }; const aiText = generateAiAnswer(trimmed, userData); const aiMsg = { id: `m_${Date.now()}_ai`, from: 'ai', text: aiText, }; setMessages((prev) => [...prev, userMsg, aiMsg]); setInput(''); if (voiceOn) { Speech.stop(); Speech.speak(aiText, { language: 'ru-RU' }); } }; const handleSend = () => { sendText(input); }; const quickPrompts = [ 'Как сократить расходы на 20%?', 'Как распределить зарплату 300 000?', 'Стоит ли покупать новый телефон?', 'Сделай обзор моих трат.', ]; return ( <KeyboardAvoidingView style={[ base.content, { backgroundColor: theme.background }, ]} behavior={Platform.OS === 'ios' ? 'padding' : undefined} > <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>ИИ-консультант</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}> Голос </Text> <Switch value={voiceOn} onValueChange={setVoiceOn} thumbColor={voiceOn ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Ответы основаны на ваших реальных данных: расходах, доходах, целях. </Text> <View style={{ marginTop: 8, flexDirection: 'row', flexWrap: 'wrap', }} > {quickPrompts.map((p) => ( <TouchableOpacity key={p} onPress={() => sendText(p)} style={[ base.badge, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.tinyText, { color: theme.text }]}>{p}</Text> </TouchableOpacity> ))} </View> </View> <View style={[ base.card, { flex: 1, backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 8 }} showsVerticalScrollIndicator={false} > {messages.map((m) => { const isUser = m.from === 'user'; return ( <View key={m.id} style={[ base.chatBubble, isUser ? base.chatUser : base.chatAi, { backgroundColor: isUser ? theme.accent : theme.cardAlt, }, ]} > <Text style={[ base.tinyText, { color: isUser ? '#FFFFFF' : theme.textMuted }, ]} > {isUser ? 'Вы' : 'ИИ'} </Text> <Text style={[ base.smallText, { color: isUser ? '#FFFFFF' : theme.text, marginTop: 2, }, ]} > {m.text} </Text> </View> ); })} </ScrollView> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, marginTop: 4, }, ]} > <TextInput placeholder="Задайте вопрос по финансам..." placeholderTextColor={theme.textMuted} value={input} onChangeText={setInput} style={[base.inputInline, { color: theme.text }]} multiline /> <TouchableOpacity onPress={handleSend}> <Text style={[base.smallText, { color: theme.accent }]}>Отпр.</Text> </TouchableOpacity> </View> </View> </KeyboardAvoidingView> ); }; const ProfileScreen = ({ currentUser, userData, onUpdateUserData, theme, onLogout, onToggleTheme, }) => { const profile = userData.profile || {}; const [firstName, setFirstName] = useState(profile.firstName || ''); const [lastName, setLastName] = useState(profile.lastName || ''); const [phone, setPhone] = useState(profile.phone || ''); const [birthDate, setBirthDate] = useState(profile.birthDate || ''); const [avatarUri, setAvatarUri] = useState(profile.avatarUri || ''); const [monthlyIncome, setMonthlyIncome] = useState( String((userData.settings && userData.settings.monthlyIncome) || ''), ); const [weeklyBudget, setWeeklyBudget] = useState( String((userData.settings && userData.settings.weeklyBudget) || ''), ); const [currency, setCurrency] = useState( (userData.settings && userData.settings.currency) || DEFAULT_CURRENCY, ); const [antiImpulseEnabled, setAntiImpulseEnabled] = useState( (userData.settings && userData.settings.antiImpulseEnabled) || false, ); const [antiImpulseThreshold, setAntiImpulseThreshold] = useState( String((userData.settings && userData.settings.antiImpulseThreshold) || ''), ); const [pinInput, setPinInput] = useState(''); const [noteDraft, setNoteDraft] = useState(''); const [noteUnlocked, setNoteUnlocked] = useState(false); useEffect(() => { const savedEncrypted = userData.secureNote || ''; if (savedEncrypted && currentUser.pin) { const dec = decryptNote(savedEncrypted, currentUser.pin); setNoteDraft(dec); } else if (savedEncrypted && !currentUser.pin) { setNoteDraft(savedEncrypted); } else { setNoteDraft(''); } }, [userData.secureNote, currentUser.pin]); useEffect(() => { const p = userData.profile || {}; setFirstName(p.firstName || ''); setLastName(p.lastName || ''); setPhone(p.phone || ''); setBirthDate(p.birthDate || ''); setAvatarUri(p.avatarUri || ''); }, [userData.profile]); const handleSaveSettings = () => { const m = parseFloat(String(monthlyIncome).replace(',', '.')) || 0; const w = parseFloat(String(weeklyBudget).replace(',', '.')) || 0; const anti = parseFloat(String(antiImpulseThreshold).replace(',', '.')) || 0; onUpdateUserData((prev) => ({ ...prev, income: m, settings: { ...(prev.settings || {}), monthlyIncome: m, weeklyBudget: w, currency: currency || DEFAULT_CURRENCY, antiImpulseEnabled, antiImpulseThreshold: anti, }, profile: { ...(prev.profile || {}), firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), birthDate: birthDate.trim(), avatarUri: avatarUri || '', }, })); Alert.alert('Сохранено', 'Профиль и бюджет обновлены.'); }; const handleUnlockNote = () => { if (!currentUser.pin) { setNoteUnlocked(true); return; } if (pinInput === currentUser.pin) { const dec = decryptNote(userData.secureNote || '', currentUser.pin); setNoteDraft(dec); setNoteUnlocked(true); } else { Alert.alert('Ошибка', 'Неверный PIN.'); } }; const handleSaveNote = () => { let encrypted = noteDraft; if (currentUser.pin) { encrypted = encryptNote(noteDraft, currentUser.pin); } onUpdateUserData((prev) => ({ ...prev, secureNote: encrypted, })); Alert.alert('Сохранено', 'Заметка зашифрована и сохранена.'); }; const totalSubs = (userData.subscriptions || []).length; const handlePickAvatar = async () => { const uri = await pickImageFromLibrary(); if (uri) setAvatarUri(uri); }; const displayName = firstName || lastName ? `${firstName} ${lastName}`.trim() : currentUser.email; return ( <ScrollView style={[ base.content, { backgroundColor: theme.background }, ]} showsVerticalScrollIndicator={false} > {/* Профиль и быстрый статус */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <View style={base.row}> <TouchableOpacity onPress={handlePickAvatar}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> {firstName ? firstName.charAt(0).toUpperCase() : currentUser.email.charAt(0).toUpperCase()} </Text> </View> )} </TouchableOpacity> <View style={{ marginLeft: 10 }}> <Text style={[base.cardTitle, { color: theme.text }]}> {displayName || 'Пользователь'} </Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> {currentUser.email} </Text> </View> </View> <TouchableOpacity onPress={onLogout}> <Text style={[base.smallText, { color: theme.danger }]}>Выйти</Text> </TouchableOpacity> </View> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Подписок учтено: {totalSubs} • PIN установлен:{' '} {currentUser.pin ? 'да' : 'нет'} </Text> <View style={{ marginTop: 12 }}> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} value={phone} onChangeText={setPhone} keyboardType="phone-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> {/* Бюджет и валюта */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Бюджет</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Установите доход, недельный бюджет и валюту — это нужно для индекса «финансовое здоровье» и рекомендаций. </Text> <TextInput placeholder="Доход в месяц" placeholderTextColor={theme.textMuted} value={monthlyIncome} onChangeText={setMonthlyIncome} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Недельный бюджет" placeholderTextColor={theme.textMuted} value={weeklyBudget} onChangeText={setWeeklyBudget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Валюта (например, ₸, ₽, $)" placeholderTextColor={theme.textMuted} value={currency} onChangeText={setCurrency} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TouchableOpacity onPress={handleSaveSettings} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить профиль и бюджет </Text> </TouchableOpacity> </View> {/* Тема */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Тема и вид</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 6 }]}> Тёмная тема </Text> <Switch value={theme.mode === 'dark'} onValueChange={onToggleTheme} thumbColor={theme.mode === 'dark' ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> В будущем сюда можно добавить иконки, виджеты и другие настройки UI. </Text> </View> {/* Анти-импульсная защита */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Контроль импульсных покупок</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧠 Эксперимент</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Включите анти-импульсный режим, чтобы приложение задавало вам «контрольный вопрос» перед крупной покупкой. </Text> <View style={[base.rowBetween, { marginTop: 8 }]}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> Анти-импульсный режим </Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Срабатывает при тратах выше порога. </Text> </View> <Switch value={antiImpulseEnabled} onValueChange={setAntiImpulseEnabled} thumbColor={antiImpulseEnabled ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> <TextInput placeholder="Порог крупной покупки (например, 50 000)" placeholderTextColor={theme.textMuted} value={antiImpulseThreshold} onChangeText={setAntiImpulseThreshold} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> </View> {/* Безопасная заметка */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Безопасная заметка</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔐 Локально</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Заметка шифруется простым алгоритмом (XOR + HEX) с вашим PIN и хранится только в AsyncStorage. Для реального проекта используйте надёжную криптографию (AES и т.п.) и биометрию. </Text> {!noteUnlocked && ( <> {currentUser.pin ? ( <> <TextInput placeholder="Введите PIN для разблокировки" placeholderTextColor={theme.textMuted} value={pinInput} onChangeText={setPinInput} keyboardType="number-pad" secureTextEntry style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleUnlockNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Разблокировать заметку </Text> </TouchableOpacity> </> ) : ( <> <Text style={[base.smallText, { color: theme.text, marginTop: 8 }]}> PIN не установлен при регистрации, поэтому заметка хранится без шифрования. </Text> <TouchableOpacity onPress={() => setNoteUnlocked(true)} style={[ base.button, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Показать заметку </Text> </TouchableOpacity> </> )} </> )} {noteUnlocked && ( <> <TextInput placeholder="Секретная финансовая заметка (пароли сюда лучше не писать)" placeholderTextColor={theme.textMuted} value={noteDraft} onChangeText={setNoteDraft} multiline style={[ base.input, { minHeight: 80, textAlignVertical: 'top', backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleSaveNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить зашифровано </Text> </TouchableOpacity> </> )} </View> </ScrollView> ); }; const MainApp = ({ currentUser, userData, setUserData, onLogout, themeMode, setThemeMode, }) => { const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME; const [activeTab, setActiveTab] = useState('home'); const handleUpdateUserData = (updater) => { setUserData((prev) => { const prevVal = prev || createDefaultUserData(themeMode); const next = typeof updater === 'function' ? updater(prevVal) : updater; return next; }); }; const handleToggleTheme = () => { setThemeMode((prev) => (prev === 'dark' ? 'light' : 'dark')); }; const profile = userData.profile || {}; const avatarUri = profile.avatarUri; const displayName = profile.firstName || profile.lastName ? `${profile.firstName || ''} ${profile.lastName || ''}`.trim() : currentUser.email; const initials = profile.firstName || profile.lastName ? `${(profile.firstName || profile.lastName || 'U').charAt(0).toUpperCase()}` : currentUser.email.charAt(0).toUpperCase(); return ( <SafeAreaView style={[ base.container, { backgroundColor: theme.background }, ]} > <StatusBar barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'} /> <View style={base.header}> <View style={base.row}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> {initials} </Text> </View> )} <View style={{ marginLeft: 10 }}> <Text style={[base.headerTitle, { color: theme.text }]}> Мой бюджет </Text> <Text style={[base.headerSubtitle, { color: theme.textMuted }]}> {displayName} </Text> </View> </View> <View style={base.chipRow}> <View style={[ base.chip, { backgroundColor: theme.accentSoft }, ]} > <Text style={[base.chipText, { color: theme.accent }]}>🤖 ИИ-чек</Text> </View> <TouchableOpacity onPress={handleToggleTheme} style={[ base.chip, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.chipText, { color: theme.text }]}> {theme.mode === 'dark' ? '◐' : '◑'} </Text> </TouchableOpacity> </View> </View> {activeTab === 'home' && <HomeScreen userData={userData} theme={theme} />} {activeTab === 'expenses' && ( <ExpensesScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'goals' && ( <GoalsScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'ai' && <AiChatScreen userData={userData} theme={theme} />} {activeTab === 'profile' && ( <ProfileScreen currentUser={currentUser} userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} onLogout={onLogout} onToggleTheme={handleToggleTheme} /> )} <View style={[ base.tabBar, { backgroundColor: theme.card, borderTopColor: theme.border }, ]} > {[ { id: 'home', label: 'Главная', icon: '🏡' }, { id: 'expenses', label: 'Траты', icon: '💳' }, { id: 'goals', label: 'Цели', icon: '📌' }, { id: 'ai', label: 'ИИ-чат', icon: '🧠' }, { id: 'profile', label: 'Профиль', icon: '👤' }, ].map((tab) => { const isActive = activeTab === tab.id; return ( <TouchableOpacity key={tab.id} onPress={() => setActiveTab(tab.id)} style={[ base.tabButton, { backgroundColor: isActive ? theme.accentSoft : 'transparent' }, ]} > <Text style={[ { fontSize: 16 }, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.icon} </Text> <Text style={[ base.tabLabel, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.label} </Text> </TouchableOpacity> ); })} </View> </SafeAreaView> ); }; // ---------- корневой компонент ---------- export default function App() { const [users, setUsers] = useState([]); const [session, setSession] = useState(null); const [userData, setUserData] = useState(null); const [themeMode, setThemeMode] = useState('dark'); const [loading, setLoading] = useState(true); const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME; useEffect(() => { const init = async () => { try { const usersJson = await AsyncStorage.getItem(STORAGE_KEYS.USERS); const sessionJson = await AsyncStorage.getItem(STORAGE_KEYS.SESSION); const loadedUsers = usersJson ? JSON.parse(usersJson) : []; setUsers(loadedUsers); if (sessionJson) { const s = JSON.parse(sessionJson); const u = loadedUsers.find((user) => user.id === s.userId); if (u) { setSession(s); const dataKey = `FINAPP_DATA_${u.id}`; const dataJson = await AsyncStorage.getItem(dataKey); if (dataJson) { const parsed = JSON.parse(dataJson); setUserData(parsed); if (parsed.settings && parsed.settings.themeMode) { setThemeMode(parsed.settings.themeMode); } } else { const defaultData = createDefaultUserData(themeMode); setUserData(defaultData); await AsyncStorage.setItem(dataKey, JSON.stringify(defaultData)); } } } } catch (e) { console.warn('Init error', e); } finally { setLoading(false); } }; init(); }, []); useEffect(() => { const persist = async () => { if (!session || !userData) return; try { const key = `FINAPP_DATA_${session.userId}`; const payload = { ...userData, settings: { ...(userData.settings || {}), themeMode, }, }; await AsyncStorage.setItem(key, JSON.stringify(payload)); } catch (e) { console.warn('Save userData error', e); } }; persist(); }, [userData, session, themeMode]); const handleLogin = async (user, profileFromRegistration = null) => { const s = { userId: user.id }; setSession(s); await AsyncStorage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(s)); const key = `FINAPP_DATA_${user.id}`; const dataJson = await AsyncStorage.getItem(key); if (dataJson) { const parsed = JSON.parse(dataJson); setUserData(parsed); if (parsed.settings && parsed.settings.themeMode) { setThemeMode(parsed.settings.themeMode); } } else { const defaultData = createDefaultUserData(themeMode, profileFromRegistration || {}); setUserData(defaultData); await AsyncStorage.setItem(key, JSON.stringify(defaultData)); } }; const handleRegister = async (newUser, profile) => { const updatedUsers = [...users, newUser]; setUsers(updatedUsers); await AsyncStorage.setItem(STORAGE_KEYS.USERS, JSON.stringify(updatedUsers)); await handleLogin(newUser, profile); }; const handleLogout = async () => { setSession(null); setUserData(null); await AsyncStorage.removeItem(STORAGE_KEYS.SESSION); }; if (loading) { return ( <SafeAreaView style={[ base.container, base.center, { backgroundColor: theme.background }, ]} > <StatusBar barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'} /> <Text style={[base.smallText, { color: theme.textMuted }]}> Загрузка данных... </Text> </SafeAreaView> ); } if (!session || !userData) { return ( <AuthScreen users={users} onLogin={handleLogin} onRegister={handleRegister} theme={theme} /> ); } const currentUser = users.find((u) => u.id === session.userId) || { id: session.userId, email: 'user', pin: '', }; return ( <MainApp currentUser={currentUser} userData={userData} setUserData={setUserData} onLogout={handleLogout} themeMode={themeMode} setThemeMode={setThemeMode} /> ); }
Если что-то всё равно не запускается в Snack (ошибка по зависимостям и т.п.) — скинь текст ошибки, и я подправлю под конкретный случай.
import React, { useEffect, useState } from 'react';
import {
SafeAreaView,
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
FlatList,
StatusBar,
KeyboardAvoidingView,
Platform,
Switch,
Alert,
Image,
ActivityIndicator,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as ImagePicker from 'expo-image-picker';
import * as Speech from 'expo-speech';
// ВАЖНО: expo-mlkit-ocr не работает в Snack / Expo Go, поэтому не импортируем его
// import ExpoMlkitOcr from 'expo-mlkit-ocr';
// ---------- константы ----------
const STORAGE_KEYS = {
USERS: 'FINAPP_USERS',
SESSION: 'FINAPP_SESSION',
};
const DARK_THEME = {
mode: 'dark',
background: '#020617',
card: '#020617',
cardAlt: '#0B1220',
accent: '#22C55E',
accentSoft: 'rgba(34,197,94,0.22)',
text: '#F9FAFB',
textMuted: '#9CA3AF',
border: '#1F2937',
danger: '#EF4444',
success: '#22C55E',
warning: '#EAB308',
};
const LIGHT_THEME = {
mode: 'light',
background: '#F3F4F6',
card: '#FFFFFF',
cardAlt: '#E5E7EB',
accent: '#0F766E',
accentSoft: 'rgba(15,118,110,0.16)',
text: '#111827',
textMuted: '#6B7280',
border: '#D1D5DB',
danger: '#DC2626',
success: '#16A34A',
warning: '#CA8A04',
};
const DEFAULT_CURRENCY = '₸';
const DEFAULT_CATEGORIES = [
'Еда',
'Транспорт',
'Жильё',
'Здоровье',
'Подписки',
'Игры/развлечения',
'Покупки',
'Сбережения',
'Другое',
];
// ---------- стили ----------
const base = StyleSheet.create({
flex1: { flex: 1 },
center: { justifyContent: 'center', alignItems: 'center' },
container: { flex: 1 },
authContainer: { flex: 1, paddingHorizontal: 24, paddingTop: 32, paddingBottom: 24 },
title: { fontSize: 28, fontWeight: '700', marginBottom: 4 },
subtitle: { fontSize: 14, marginBottom: 24 },
input: {
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 14,
marginBottom: 12,
},
button: {
borderRadius: 18,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
marginTop: 4,
},
buttonText: { fontSize: 15, fontWeight: '600' },
link: { fontSize: 14, marginTop: 12, textAlign: 'center' },
errorText: { fontSize: 13, marginBottom: 8 },
header: {
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerTitle: { fontSize: 22, fontWeight: '700' },
headerSubtitle: { fontSize: 12 },
chipRow: { flexDirection: 'row', alignItems: 'center' },
chip: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
marginLeft: 8,
},
chipText: { fontSize: 12 },
content: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 4,
},
card: {
borderRadius: 20,
padding: 16,
marginBottom: 14,
shadowColor: '#000',
shadowOpacity: 0.14,
shadowRadius: 12,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
cardHeaderRow: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 6,
},
cardTitle: { fontSize: 16, fontWeight: '600' },
cardSubtitle: { fontSize: 12 },
row: { flexDirection: 'row', alignItems: 'center' },
rowBetween: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
pill: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
marginLeft: 6,
},
pillText: { fontSize: 11, fontWeight: '500' },
tabBar: {
flexDirection: 'row',
paddingHorizontal: 6,
paddingVertical: 8,
borderTopWidth: StyleSheet.hairlineWidth,
},
tabButton: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
borderRadius: 999,
marginHorizontal: 4,
},
tabLabel: { fontSize: 11, marginTop: 2 },
badgeDot: {
width: 8,
height: 8,
borderRadius: 999,
marginRight: 4,
},
smallText: { fontSize: 12 },
tinyText: { fontSize: 11 },
sectionTitle: { fontSize: 14, fontWeight: '600', marginBottom: 8 },
inputRow: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 14,
paddingHorizontal: 10,
marginBottom: 8,
},
inputInline: { flex: 1, paddingVertical: 6, fontSize: 13 },
expenseItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 10,
},
divider: {
height: StyleSheet.hairlineWidth,
marginVertical: 6,
},
tag: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
marginRight: 6,
marginTop: 4,
},
tagText: { fontSize: 11 },
chartRow: { marginTop: 6 },
chartBarBg: {
height: 10,
borderRadius: 999,
overflow: 'hidden',
},
chartBarFill: {
height: 10,
borderRadius: 999,
},
chatBubble: {
padding: 10,
borderRadius: 14,
marginVertical: 4,
maxWidth: '85%',
},
chatUser: { alignSelf: 'flex-end' },
chatAi: { alignSelf: 'flex-start' },
badge: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 999,
marginRight: 6,
marginBottom: 6,
},
avatarCircle: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
avatarImage: {
width: 44,
height: 44,
borderRadius: 22,
},
goalImage: {
width: '100%',
height: 120,
borderRadius: 12,
marginTop: 6,
},
});
// ---------- helpers ----------
const formatCurrency = (amount, currency = DEFAULT_CURRENCY) => {
const n = Number.isFinite(amount) ? amount : 0;
const rounded = Math.round(n);
return ${currency} ${rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')};
};
const formatDate = (isoString) => {
if (!isoString) return '';
const d = new Date(isoString);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('ru-RU', {
day: '2-digit',
month: 'short',
});
};
const getMonthKey = (date) =>
${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')};
const guessCategory = (title) => {
if (!title) return 'Другое';
const t = title.toLowerCase();
if (/(super|market|магазин|магнит|кошелек|grocery|food|еда|dodo|kfc|burger)/.test(t)) return 'Еда';
if (/(аптека|pharm|apteka|drug)/.test(t)) return 'Здоровье';
if (/(taxi|такси|yandex go|bolt|uber|bus|metro)/.test(t)) return 'Транспорт';
if (/(аренда|rent|ipotek|ипотека|квартира)/.test(t)) return 'Жильё';
if (/(netflix|spotify|youtube|yandex plus|подписка|subscription)/.test(t)) return 'Подписки';
if (/(steam|psn|xbox|game|игр)/.test(t)) return 'Игры/развлечения';
if (/(cafe|кафе|coffee|кофе|restaurant|ресторан)/.test(t)) return 'Еда';
if (/(ozon|wildberries|wb|aliexpress)/.test(t)) return 'Покупки';
return 'Другое';
};
const sumBy = (arr, fn) => arr.reduce((acc, item, idx) => acc + (fn(item, idx) || 0), 0);
const buildMonthlyTotals = (expenses) => {
const totals = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
const key = getMonthKey(d);
totals[key] = (totals[key] || 0) + (Number(e.amount) || 0);
});
return totals;
};
const buildCategoryTotalsForMonth = (expenses, monthKey) => {
const m = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
if (getMonthKey(d) !== monthKey) return;
const cat = e.category || 'Другое';
m[cat] = (m[cat] || 0) + (Number(e.amount) || 0);
});
return m;
};
const forecastNextMonth = (expenses) => {
const totalsMap = buildMonthlyTotals(expenses);
const keys = Object.keys(totalsMap).sort();
if (keys.length === 0) return 0;
const ys = keys.map((k) => totalsMap[k]);
if (keys.length === 1) return ys[0];
const n = ys.length;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumX2 = 0;
for (let i = 0; i < n; i += 1) {
const x = i;
const y = ys[i];
sumX += x;
sumY += y;
sumXY += x * y;
sumX2 += x * x;
}
const denom = n * sumX2 - sumX * sumX;
const slope = denom === 0 ? 0 : (n * sumXY - sumX * sumY) / denom;
const intercept = (sumY - slope * sumX) / n;
const forecast = intercept + slope * n;
return forecast > 0 ? forecast : ys[ys.length - 1];
};
const detectAnomalies = (expenses) => {
if (!expenses.length) return [];
const amounts = expenses.map((e) => Number(e.amount) || 0);
const avg = sumBy(amounts, (a) => a) / amounts.length;
const threshold = Math.max(avg * 2, 0);
return expenses.filter((e) => (Number(e.amount) || 0) > threshold && threshold > 0);
};
const calculateDailyStreak = (expenses, monthlyIncome) => {
if (!expenses.length || !monthlyIncome) return 0;
const dailyBudget = monthlyIncome / 30;
if (dailyBudget <= 0) return 0;
const byDay = {};
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
const key = d.toISOString().substring(0, 10);
byDay[key] = (byDay[key] || 0) + (Number(e.amount) || 0);
});
const today = new Date();
let streak = 0;
for (let i = 0; i < 365; i += 1) {
const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000);
const key = d.toISOString().substring(0, 10);
const spent = byDay[key] || 0;
if (spent <= dailyBudget) {
streak += 1;
} else {
break;
}
}
return streak;
};
const countNoSpendDaysThisMonth = (expenses) => {
if (!expenses.length) return 0;
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const spentDays = new Set();
expenses.forEach((e) => {
if (e.type === 'income') return;
const d = new Date(e.date || new Date());
if (d.getFullYear() !== year || d.getMonth() !== month) return;
spentDays.add(d.getDate());
});
return daysInMonth - spentDays.size;
};
const calculateFinancialHealth = (userData) => {
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const monthlyIncome = Number(settings.monthlyIncome) || Number(userData.income) || 0;
const now = new Date();
const currentMonthKey = getMonthKey(now);
const monthTotals = buildMonthlyTotals(expenses);
const totalThisMonth = monthTotals[currentMonthKey] || 0;
const savings = Math.max(0, monthlyIncome - totalThisMonth);
const savingsRate = monthlyIncome > 0 ? savings / monthlyIncome : 0;
const unnecessary = sumBy(expenses, (e) => {
if (e.type === 'income') return 0;
const d = new Date(e.date || new Date());
if (getMonthKey(d) !== currentMonthKey) return 0;
const c = (e.category || '').toLowerCase();
if (
c.includes('игр') ||
c.includes('развлеч') ||
c.includes('подпис') ||
c.includes('покуп') ||
c.includes('кафе') ||
c.includes('bar') ||
c.includes('club')
) {
return Number(e.amount) || 0;
}
return 0;
});
const unnecessaryShare = monthlyIncome > 0 ? unnecessary / monthlyIncome : 0;
let score = 50;
score += Math.min(20, savingsRate * 40);
score -= Math.min(15, unnecessaryShare * 30);
if (userData.goals && userData.goals.length) score += 5;
if (expenses.length > 20) score += 5;
score = Math.max(0, Math.min(100, Math.round(score)));
let label = 'Нужно больше данных';
if (score >= 80) {
label = 'Отличное состояние';
} else if (score >= 60) {
label = 'Хорошее состояние';
} else if (score >= 40) {
label = 'Среднее, есть над чем поработать';
} else {
label = 'Слабое, нужен план';
}
return {
score,
label,
monthlyIncome,
totalThisMonth,
savings,
unnecessary,
};
};
// простое шифрование заметок по PIN (XOR + hex, не для продакшена)
const encryptNote = (plain, pin) => {
if (!plain) return '';
if (!pin) return plain;
const digits = pin.split('').map((d) => parseInt(d, 10) || 0);
if (!digits.length) return plain;
let out = '';
for (let i = 0; i < plain.length; i += 1) {
const code = plain.charCodeAt(i) ^ digits[i % digits.length];
out += code.toString(16).padStart(2, '0');
}
return out;
};
const decryptNote = (cipher, pin) => {
if (!cipher) return '';
if (!pin) return cipher;
const digits = pin.split('').map((d) => parseInt(d, 10) || 0);
if (!digits.length) return cipher;
try {
let out = '';
for (let i = 0; i < cipher.length; i += 2) {
const hex = cipher.substr(i, 2);
const code = parseInt(hex, 16) ^ digits[(i / 2) % digits.length];
out += String.fromCharCode(code);
}
return out;
} catch (e) {
return '';
}
};
const buildSmartReminders = (userData) => {
const reminders = [];
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const now = new Date();
const nowKey = getMonthKey(now);
const last7Total = sumBy(expenses, (e) => {
if (e.type === 'income') return 0;
const d = new Date(e.date || new Date());
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24);
if (diff >= 0 && diff <= 7) {
return Number(e.amount) || 0;
}
return 0;
});
const weeklyBudget = Number(settings.weeklyBudget) || 0;
if (weeklyBudget > 0 && last7Total > weeklyBudget) {
reminders.push(
За последние 7 дней вы уже вышли за недельный бюджет на ${formatCurrency( last7Total - weeklyBudget, settings.currency || DEFAULT_CURRENCY, )}.,
);
} else if (weeklyBudget > 0 && last7Total > weeklyBudget * 0.8) {
reminders.push('Вы приблизились к недельному бюджету — самое время притормозить расходы.');
}
(userData.goals || []).forEach((g) => {
const target = Number(g.targetAmount) || 0;
const saved = Number(g.savedAmount) || 0;
if (!target) return;
const d = g.deadline ? new Date(g.deadline) : null;
const left = target - saved;
if (left <= 0 || !d) return;
const daysLeft = Math.round((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (daysLeft <= 0) return;
const perDay = left / daysLeft;
if (perDay > 0) {
reminders.push(
До цели «${g.title}» осталось ${formatCurrency( left, settings.currency || DEFAULT_CURRENCY, )}. Нужно откладывать примерно ${formatCurrency( perDay, settings.currency || DEFAULT_CURRENCY, )} в день.,
);
}
});
const monthTotals = buildMonthlyTotals(expenses);
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevKey = getMonthKey(prevMonth);
const totalNow = monthTotals[nowKey] || 0;
const totalPrev = monthTotals[prevKey] || 0;
if (totalPrev > 0 && totalNow > totalPrev * 1.15) {
const diff = totalNow - totalPrev;
reminders.push(
В этом месяце ваши траты уже на ${formatCurrency( diff, settings.currency || DEFAULT_CURRENCY, )} больше, чем в прошлом.,
);
}
if (!reminders.length) {
reminders.push('Вы молодец — критичных напоминаний сейчас нет 🎉');
}
return reminders;
};
const buildInsightsSummary = (userData) => {
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const now = new Date();
const currentMonthKey = getMonthKey(now);
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevKey = getMonthKey(prevMonth);
const monthTotals = buildMonthlyTotals(expenses);
const totalNow = monthTotals[currentMonthKey] || 0;
const totalPrev = monthTotals[prevKey] || 0;
const catNow = buildCategoryTotalsForMonth(expenses, currentMonthKey);
const catPrev = buildCategoryTotalsForMonth(expenses, prevKey);
const entriesNow = Object.entries(catNow);
const topCat = entriesNow.sort((a, b) => b[1] - a[1])[0];
const currency = settings.currency || DEFAULT_CURRENCY;
const anomalies = detectAnomalies(expenses).slice(0, 3);
const lines = [];
if (totalPrev > 0 && totalNow > 0) {
const delta = totalNow - totalPrev;
const pct = (delta / totalPrev) * 100;
if (Math.abs(pct) >= 5) {
if (pct > 0) {
lines.push(
Общие расходы выросли на ${pct.toFixed(1)}% по сравнению с прошлым месяцем (${formatCurrency( totalNow, currency, )} против ${formatCurrency(totalPrev, currency)}).,
);
} else {
lines.push(
Вы тратите на ${Math.abs(pct).toFixed( 1, )}% меньше, чем в прошлом месяце — отличный прогресс 👏,
);
}
}
}
if (topCat) {
lines.push(
Больше всего вы тратите на категорию «${topCat[0]}» — ${formatCurrency( topCat[1], currency, )} в этом месяце.,
);
}
Object.keys(catNow).forEach((c) => {
const nowVal = catNow[c];
const prevVal = catPrev[c] || 0;
if (prevVal <= 0) return;
const pct = (nowVal - prevVal) / prevVal;
if (pct >= 0.25 && nowVal > 0) {
lines.push(
Категория «${c}» выросла на ${Math.round( pct * 100, )}% относительно прошлого месяца — стоит проверить, можно ли там сэкономить.,
);
}
});
if (anomalies.length) {
const txt = anomalies
.map((a) => "${a.title}" на ${formatCurrency(a.amount, currency)})
.join(', ');
lines.push(Обнаружены разовые крупные траты: ${txt}. Убедитесь, что они действительно нужны.);
}
if (!lines.length) {
lines.push('Пока недостаточно данных для глубокого анализа. Начните добавлять траты 🙂');
}
return lines;
};
const generateAiAnswer = (promptRaw, userData) => {
const prompt = promptRaw.toLowerCase();
const health = calculateFinancialHealth(userData);
const insights = buildInsightsSummary(userData);
const currency = (userData.settings && userData.settings.currency) || DEFAULT_CURRENCY;
const expenses = userData.expenses || [];
const monthlyTotals = buildMonthlyTotals(expenses);
const nowKey = getMonthKey(new Date());
const totalThisMonth = monthlyTotals[nowKey] || 0;
if (!expenses.length && !prompt.includes('регистрац')) {
return 'У меня пока нет ваших данных. Добавьте несколько трат и доходов — тогда я смогу давать персональные советы.';
}
if (prompt.includes('сократить') || prompt.includes('эконом') || prompt.includes('сэкономить')) {
const catNow = buildCategoryTotalsForMonth(expenses, nowKey);
const entries = Object.entries(catNow).sort((a, b) => b[1] - a[1]);
const top3 = entries.slice(0, 3);
const tips = top3
.map(
([c, v]) =>
• «${c}» — ${formatCurrency( v, currency, )} в месяц. Попробуйте снизить хотя бы на 10–20%.,
)
.join('\n');
return (
Сейчас ваши траты за месяц: ${formatCurrency( totalThisMonth, currency, )}. Чтобы сократить их, лучше всего начать с самых крупных категорий:\n\n${tips}\n\n +
'Дополнительно:\n' +
'• установите недельный бюджет и фиксируйте все траты;\n' +
'• избегайте спонтанных покупок — делайте «паузу 24 часа»;\n' +
'• для подписок — оставьте только то, чем пользуетесь каждую неделю.'
);
}
if (prompt.includes('как распределить') || prompt.includes('зарплат')) {
const match = promptRaw.match(/(\d[\d\s]{2,})/);
const income = match ? parseInt(match[1].replace(/\s/g, ''), 10) : health.monthlyIncome || 0;
if (!income) {
return 'Напишите, пожалуйста, размер зарплаты (например: «Как распределить зарплату 300 000?»), или задайте месячный доход в настройках. Тогда я предложу детальный план распределения.';
}
const needs = income * 0.5;
const wants = income * 0.3;
const saving = income * 0.2;
return (
Классическая модель 50/30/20 для зарплаты ${formatCurrency(income, currency)}:\n\n +
• 50% (${formatCurrency(needs, currency)}) — обязательные расходы (жильё, еда, транспорт, кредиты);\n +
• 30% (${formatCurrency(wants, currency)}) — «хотелки» и развлечения;\n +
• 20% (${formatCurrency(saving, currency)}) — сбережения и цели.\n\n +
'Вы можете адаптировать: если есть кредиты — уменьшите «хотелки» до 10–15% и направьте разницу на досрочное погашение и накопления.'
);
}
if (prompt.includes('стоит ли покупать') || prompt.includes('покупать')) {
return (
'Задайте себе три вопроса перед покупкой:\n' +
'1) Нужна ли вещь прямо сейчас или это импульс?\n' +
'2) Сколько часов вашей работы она стоит?\n' +
'3) Через месяц вы будете так же рады этой покупке?\n\n' +
'Если сумма покупки >10% от месячного дохода — хорошо бы выдержать «паузу 24 часа». ' +
'Также посмотрите, не мешает ли эта покупка вашим финансовым целям (накоплениям и т.п.).'
);
}
if (prompt.includes('что если') || prompt.includes('если буду')) {
return (
'Функция «Что если?» уже доступна на вкладке «Цели» — там можно смоделировать, что будет, если сократить траты на кафе или начать откладывать больше. ' +
'Если хотите, сформулируйте сценарий конкретно (например: «Что будет, если каждый месяц откладывать на 20 000 больше?»), и я прикину эффект в чате.'
);
}
const baseInsights = insights.slice(0, 2).join('\n\n');
return (
Краткий обзор вашего финансового состояния:\n +
• Индекс финансового здоровья: ${health.score}/100 (${health.label}).\n +
• Траты в этом месяце: ${formatCurrency(totalThisMonth, currency)}.\n +
• Потенциальные сбережения: ${formatCurrency(health.savings, currency)}.\n\n +
${baseInsights}\n\n +
'Задавайте мне конкретные вопросы: например, «Как сократить расходы на 20%?» или ' +
'«Помоги перераспределить бюджет с учётом цели на 800 000».'
);
};
// helper для дефолтных данных пользователя
const createDefaultUserData = (themeMode, profile = {}) => ({
income: 0,
expenses: [],
goals: [],
subscriptions: [],
settings: {
monthlyIncome: 0,
weeklyBudget: 0,
currency: DEFAULT_CURRENCY,
antiImpulseEnabled: false,
antiImpulseThreshold: 0,
themeMode: themeMode || 'dark',
},
profile,
secureNote: '',
});
// helper для выбора картинки из галереи
const pickImageFromLibrary = async () => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Нет доступа', 'Разрешите доступ к фото в настройках устройства.');
return null;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.8,
});
if (result.canceled) return null;
const asset = result.assets && result.assets[0];
if (!asset) return null;
return asset.uri;
};
// ---------- OCR + ИИ-проверка чеков ----------
// ⚠️ Snack / Expo Go не поддерживает expo-mlkit-ocr из коробки.
// Поэтому здесь заглушка, чтобы код работал в онлайн-Snack.
// В реальном приложении на EAS dev-client можно вернуть оригинальный код с ML Kit.
const runOcrOnReceiptImage = async (uri) => {
console.log('runOcrOnReceiptImage: stub on Snack, uri:', uri);
if (Platform.OS === 'web') {
Alert.alert(
'OCR недоступен',
'Распознавание чеков не поддерживается в веб-версии Snack. Введите сумму вручную.',
);
} else {
Alert.alert(
'OCR недоступен в Snack',
'Для сканирования чеков через ML Kit нужен кастомный dev-клиент. В демо на Snack сумму нужно вводить вручную.',
);
}
// Возвращаем текст, чтобы показать превью, но сумма из него не извлечётся
return '(OCR недоступен в Snack — введите сумму вручную)';
};
// «Умный» парсер текста чека: пытается понять именно итоговую сумму
const parseReceiptOcrText = (text) => {
if (!text) return null;
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) return null;
// Денежный паттерн: 1 234,56 / 1234.56 / 12 345 / 12345 и т.п.
const MONEY_REGEX =
/\d{1,3}(?:[ \u00A0]\d{3})*(?:[.,]\d{1,2})?|\d+[.,]\d{1,2}/g;
const parseMoney = (raw) => {
if (!raw) return NaN;
let cleaned = String(raw)
.replace(/\u00A0/g, ' ')
.replace(/[^\d.,\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
textif (!cleaned) return NaN; cleaned = cleaned.replace(/\s/g, ''); if (cleaned.includes(',') && cleaned.includes('.')) { const lastComma = cleaned.lastIndexOf(','); const lastDot = cleaned.lastIndexOf('.'); if (lastComma > lastDot) { cleaned = cleaned.replace(/\./g, '').replace(',', '.'); } else { cleaned = cleaned.replace(/,/g, ''); } } else if (cleaned.includes(',')) { cleaned = cleaned.replace(',', '.'); } const value = parseFloat(cleaned); return Number.isFinite(value) ? value : NaN;
};
const isTotalLine = (line) => {
const lower = line.toLowerCase().replace(/\s+/g, ' ');
return (
/итог(о)?/.test(lower) ||
/к оплате/.test(lower) ||
/сумма к оплате/.test(lower) ||
/итог по чеку/.test(lower) ||
/total/.test(lower)
);
};
let amount = null;
// 1) Ищем строку с ИТОГ / К ОПЛАТЕ и берём сумму оттуда
for (const line of lines) {
if (!isTotalLine(line)) continue;
textconst matches = line.match(MONEY_REGEX); if (matches && matches.length) { const raw = matches[matches.length - 1]; const v = parseMoney(raw); if (Number.isFinite(v) && v > 0) { amount = v; break; } }
}
// 2) Если не нашли — смотрим последние 3–5 строк
if (amount == null) {
const tail = lines.slice(-5);
const candidates = [];
texttail.forEach((line) => { const matches = line.match(MONEY_REGEX); if (!matches) return; matches.forEach((m) => { const v = parseMoney(m); if (Number.isFinite(v) && v > 0 && v < 1e7) { candidates.push(v); } }); }); if (candidates.length) { amount = Math.max(...candidates); }
}
// 3) Совсем fallback — ищем по всему тексту
if (amount == null) {
const candidates = [];
lines.forEach((line) => {
const matches = line.match(MONEY_REGEX);
if (!matches) return;
matches.forEach((m) => {
const v = parseMoney(m);
if (Number.isFinite(v) && v > 0 && v < 1e7) {
candidates.push(v);
}
});
});
textif (candidates.length) { amount = Math.max(...candidates); }
}
// 4) Название магазина / покупки
let titleLine = '';
for (const line of lines) {
const letters = line.replace(/[^A-Za-zА-Яа-яЁё]/g, '');
if (!letters.length) continue;
textif (isTotalLine(line)) continue; if (/кассир|смена|чек|фискальн|ккм|рнк|зн/i.test(line)) continue; titleLine = line; break;
}
if (!titleLine) {
titleLine = 'Покупка по чеку';
}
const category = guessCategory(titleLine);
return {
title: titleLine,
amount: Number.isFinite(amount) && amount > 0 ? amount : 0,
category,
};
};
// ---------- небольшие компоненты ----------
const CategoryBarChart = ({ data, theme }) => {
if (!data || !data.length) return null;
const max = Math.max(...data.map((d) => d.value || 0));
if (!max) return null;
return (
<View style={base.chartRow}>
{data.map((item) => {
const pct = (item.value / max) * 100;
return (
<View key={item.label} style={{ marginBottom: 6 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>{item.label}</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{formatCurrency(item.value)}
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 3 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: theme.accent,
width: ${pct}%,
},
]}
/>
</View>
</View>
);
})}
</View>
);
};
const SimpleMonthlyChart = ({ expenses, theme }) => {
const totalsMap = buildMonthlyTotals(expenses || []);
const keys = Object.keys(totalsMap).sort();
if (!keys.length) return null;
const items = keys.slice(-6).map((k) => ({ key: k, value: totalsMap[k] }));
const max = Math.max(...items.map((i) => i.value));
if (!max) return null;
return (
<View style={{ marginTop: 4 }}>
{items.map((item) => {
const [year, month] = item.key.split('-');
const label = ${month}.${year.slice(-2)};
const pct = (item.value / max) * 100;
return (
<View key={item.key} style={{ marginBottom: 4 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>{label}</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{formatCurrency(item.value)}
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 3 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: theme.accentSoft,
width: ${pct}%,
},
]}
/>
</View>
</View>
);
})}
</View>
);
};
const SpendingGauge = ({ monthlyIncome, spent, theme, currency }) => {
if (!monthlyIncome) return null;
const ratio = monthlyIncome > 0 ? spent / monthlyIncome : 0;
const pct = Math.max(0, Math.min(1, ratio));
const labelPct = (pct * 100).toFixed(0);
return (
<View style={{ marginTop: 10 }}>
<View style={base.rowBetween}>
<Text style={[base.tinyText, { color: theme.textMuted }]}>Расходы от дохода</Text>
<Text style={[base.tinyText, { color: theme.text }]}>
{labelPct}% ({formatCurrency(spent, currency)})
</Text>
</View>
<View
style={[
base.chartBarBg,
{ backgroundColor: theme.cardAlt, marginTop: 4, height: 12 },
]}
>
<View
style={[
base.chartBarFill,
{
backgroundColor: ratio > 1 ? theme.danger : theme.accent,
width: ${pct * 100}%,
},
]}
/>
</View>
{ratio > 1 && (
<Text style={[base.tinyText, { color: theme.danger, marginTop: 4 }]}>
Вы вышли за рамки месячного дохода — стоит пересмотреть план.
</Text>
)}
</View>
);
};
// ---------- экраны ----------
const AuthScreen = ({ users, onLogin, onRegister, theme }) => {
const [mode, setMode] = useState('login'); // 'login' | 'register'
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [pin, setPin] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [phone, setPhone] = useState('');
const [birthDate, setBirthDate] = useState('');
const [avatarUri, setAvatarUri] = useState('');
const [error, setError] = useState('');
const toggleMode = () => {
setError('');
setMode((m) => (m === 'login' ? 'register' : 'login'));
};
const handleLoginPress = () => {
setError('');
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail || !password) {
setError('Заполните email и пароль.');
return;
}
const user = users.find((u) => u.email === normalizedEmail);
if (!user || user.password !== password) {
setError('Неверный email или пароль.');
return;
}
onLogin(user);
};
const handleRegisterPress = () => {
setError('');
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail || !password) {
setError('Заполните email и пароль.');
return;
}
if (!normalizedEmail.includes('@')) {
setError('Некорректный email.');
return;
}
if (password.length < 4) {
setError('Пароль должен быть не короче 4 символов.');
return;
}
if (!firstName.trim()) {
setError('Укажите имя.');
return;
}
if (!phone.trim()) {
setError('Укажите номер телефона.');
return;
}
if (users.some((u) => u.email === normalizedEmail)) {
setError('Пользователь с таким email уже существует.');
return;
}
const newUser = {
id: u_${Date.now()},
email: normalizedEmail,
password,
pin: pin || '',
createdAt: new Date().toISOString(),
};
const profile = {
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim(),
birthDate: birthDate.trim(),
avatarUri: avatarUri || '',
};
onRegister(newUser, profile);
};
const handlePickAvatar = async () => {
const uri = await pickImageFromLibrary();
if (uri) {
setAvatarUri(uri);
}
};
return (
<SafeAreaView
style={[
base.container,
{ backgroundColor: theme.background },
]}
>
<StatusBar
barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'}
/>
<KeyboardAvoidingView
style={base.flex1}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={[base.authContainer]}>
<View style={{ marginBottom: 32 }}>
<Text style={[base.title, { color: theme.text }]}>
Финансовый ИИ-помощник
</Text>
<Text style={[base.subtitle, { color: theme.textMuted }]}>
Управляй доходами, расходами, целями и сканируй чеки с ИИ.
</Text>
</View>
text<View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[ base.cardTitle, { color: theme.text }, ]} > {mode === 'login' ? 'Вход' : 'Регистрация'} </Text> <View style={[ base.pill, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.pillText, { color: theme.accent }, ]} > ⚡ ИИ + чек-сканер </Text> </View> </View> {error ? ( <Text style={[base.errorText, { color: theme.danger }]}>{error}</Text> ) : null} {mode === 'register' && ( <> <View style={base.row}> <TouchableOpacity onPress={handlePickAvatar}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> + </Text> </View> )} </TouchableOpacity> <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото профиля (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте аватар, чтобы приложение стало более личным. </Text> </View> </View> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} keyboardType="phone-pad" value={phone} onChangeText={setPhone} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </> )} <TextInput placeholder="Email" placeholderTextColor={theme.textMuted} autoCapitalize="none" keyboardType="email-address" value={email} onChangeText={setEmail} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Пароль" placeholderTextColor={theme.textMuted} secureTextEntry value={password} onChangeText={setPassword} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> {mode === 'register' && ( <TextInput placeholder="PIN (необязательно, для шифрования заметок)" placeholderTextColor={theme.textMuted} value={pin} onChangeText={setPin} keyboardType="number-pad" maxLength={6} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> )} <TouchableOpacity onPress={mode === 'login' ? handleLoginPress : handleRegisterPress} style={[ base.button, { backgroundColor: theme.accent, marginTop: 8 }, ]} > <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {mode === 'login' ? 'Войти' : 'Зарегистрироваться'} </Text> </TouchableOpacity> <TouchableOpacity onPress={toggleMode}> <Text style={[ base.link, { color: theme.textMuted }, ]} > {mode === 'login' ? 'Нет аккаунта? Создать' : 'Уже есть аккаунт? Войти'} </Text> </TouchableOpacity> </View> <View style={{ marginTop: 24 }}> <Text style={[ base.smallText, { color: theme.textMuted }, ]} > Данные хранятся только локально на устройстве (AsyncStorage). Для реального приложения стоит добавить серверный бэкап и более серьёзную криптографию. </Text> </View> </View> </KeyboardAvoidingView> </SafeAreaView>
);
};
const HomeScreen = ({ userData, theme }) => {
const health = calculateFinancialHealth(userData);
const insights = buildInsightsSummary(userData);
const reminders = buildSmartReminders(userData);
const expenses = userData.expenses || [];
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const now = new Date();
const currentMonthKey = getMonthKey(now);
const catNowMap = buildCategoryTotalsForMonth(expenses, currentMonthKey);
const categoryData = Object.entries(catNowMap)
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value);
const forecast = forecastNextMonth(expenses);
const streak = calculateDailyStreak(
expenses,
Number(settings.monthlyIncome) || Number(userData.income) || 0,
);
const noSpendDays = countNoSpendDaysThisMonth(expenses);
const profile = userData.profile || {};
const firstName = profile.firstName || '';
const badges = [];
if (health.score >= 80) badges.push('Экономист месяца');
if (streak >= 7) badges.push('7 дней в плюсе');
if (noSpendDays >= 5) badges.push('Дни без трат');
if (expenses.length >= 50) badges.push('Финансовый самурай');
return (
<ScrollView
style={[
base.content,
{ backgroundColor: theme.background },
]}
showsVerticalScrollIndicator={false}
>
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.rowBetween}>
<View style={{ flex: 1 }}>
<Text style={[base.cardSubtitle, { color: theme.textMuted }]}>
Индекс финансового здоровья
</Text>
<Text
style={[
{ fontSize: 32, fontWeight: '700', marginTop: 4 },
{ color: theme.text },
]}
>
{health.score}
<Text style={{ fontSize: 16 }}>/100</Text>
</Text>
{firstName ? (
<Text
style={[
base.tinyText,
{ color: theme.textMuted, marginTop: 4 },
]}
>
{firstName}, это ваш персональный индекс.
</Text>
) : null}
</View>
<View
style={[
{
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
},
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.tinyText, { color: theme.textMuted }]}>Статус</Text>
<Text
style={[
base.smallText,
{ color: theme.text, fontWeight: '600', marginTop: 2 },
]}
>
{health.label}
</Text>
</View>
</View>
text<View style={{ marginTop: 10 }}> <View style={base.rowBetween}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Доход / мес.</Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {health.monthlyIncome ? formatCurrency(health.monthlyIncome, currency) : 'Не задан'} </Text> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Траты в этом месяце </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(health.totalThisMonth, currency)} </Text> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Сбережения</Text> <Text style={[ base.smallText, { color: health.savings >= 0 ? theme.success : theme.danger, marginTop: 2, }, ]} > {formatCurrency(health.savings, currency)} </Text> </View> <SpendingGauge monthlyIncome={health.monthlyIncome} spent={health.totalThisMonth} theme={theme} currency={currency} /> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Привычки</Text> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Серия дней в рамках бюджета: {streak} </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Дней без трат в этом месяце: {noSpendDays} </Text> </View> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Бейджи</Text> <View style={[base.row, { flexWrap: 'wrap', marginTop: 4 }]}> {badges.length ? ( badges.map((b) => ( <View key={b} style={[ base.badge, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.tinyText, { color: theme.accent }, ]} > ✨ {b} </Text> </View> )) ) : ( <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Собирайте награды, держась в рамках бюджета. </Text> )} </View> </View> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>AI-инсайты</Text> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>🧩 Анализ</Text> </View> </View> {insights.slice(0, 3).map((line, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{line}</Text> </View> ))} </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Категории</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Текущий месяц </Text> </View> <CategoryBarChart data={categoryData} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Динамика</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Прогноз: {formatCurrency(forecast, currency)} </Text> </View> <SimpleMonthlyChart expenses={expenses} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Умные напоминания</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔔</Text> </View> {reminders.map((r, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{r}</Text> </View> ))} </View> </ScrollView>
);
};
const ExpensesScreen = ({ userData, onUpdateUserData, theme }) => {
const [title, setTitle] = useState('');
const [amount, setAmount] = useState('');
const [category, setCategory] = useState('');
const [isIncome, setIsIncome] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [lastReceiptPreview, setLastReceiptPreview] = useState(null); // { uri, text }
const expenses = [...(userData.expenses || [])].sort(
(a, b) => new Date(b.date || 0).getTime() - new Date(a.date || 0).getTime(),
);
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const antiImpulseEnabled = settings.antiImpulseEnabled;
const antiImpulseThreshold = Number(settings.antiImpulseThreshold) || 0;
const addEntry = (n, guessed, customTitle, extraMeta) => {
const entry = {
id: exp_${Date.now()},
title: (customTitle || title).trim(),
amount: n,
category: guessed,
type: isIncome ? 'income' : 'expense',
date: new Date().toISOString(),
...(extraMeta ? { meta: extraMeta } : {}),
};
onUpdateUserData((prev) => ({
...prev,
expenses: [...(prev.expenses || []), entry],
}));
setTitle('');
setAmount('');
setCategory('');
setIsIncome(false);
};
const handleAdd = () => {
const n = parseFloat(String(amount).replace(',', '.'));
if (!title || !Number.isFinite(n) || n <= 0) {
Alert.alert('Ошибка', 'Введите название и сумму.');
return;
}
const guessed = category || guessCategory(title);
textif (!isIncome && antiImpulseEnabled && antiImpulseThreshold > 0 && n >= antiImpulseThreshold) { Alert.alert( 'Анти-импульсная защита', `Покупка на ${formatCurrency( n, currency, )} превышает ваш лимит в ${formatCurrency(antiImpulseThreshold, currency)}.\n\n` + 'Вы уверены, что хотите продолжить?', [ { text: 'Отменить', style: 'cancel' }, { text: 'Купить', style: 'destructive', onPress: () => addEntry(n, guessed), }, ], ); return; } addEntry(n, guessed);
};
const handleScanReceipt = async () => {
try {
setIsScanning(true);
const perm = await ImagePicker.requestCameraPermissionsAsync();
if (!perm.granted) {
Alert.alert('Нет доступа', 'Разрешите доступ к камере в настройках устройства.');
return;
}
textconst result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8, }); if (result.canceled) return; const asset = result.assets && result.assets[0]; if (!asset) return; const uri = asset.uri; const ocrText = await runOcrOnReceiptImage(uri); setLastReceiptPreview({ uri, text: ocrText }); const parsed = parseReceiptOcrText(ocrText); if (!parsed || !parsed.amount || parsed.amount <= 0) { Alert.alert( 'Не удалось распознать чек', 'На Snack распознавание суммы недоступно. Введите сумму вручную в блоке «Новая запись».', ); return; } const guessedCategory = parsed.category || guessCategory(parsed.title); const n = parsed.amount; // Авто-добавление в расходы onUpdateUserData((prev) => ({ ...prev, expenses: [ ...(prev.expenses || []), { id: `exp_receipt_${Date.now()}`, title: parsed.title || 'Покупка по чеку', amount: n, category: guessedCategory, type: 'expense', date: new Date().toISOString(), meta: { source: 'receipt', ocrText, imageUri: uri, }, }, ], })); Alert.alert( 'Чек добавлен', `Мы распознали сумму ${formatCurrency( n, currency, )} и категорию «${guessedCategory}».\nЗапись добавлена в историю расходов.`, ); } catch (e) { console.warn('Receipt scan error', e); Alert.alert('Ошибка', 'Не удалось обработать чек. Попробуйте ещё раз.'); } finally { setIsScanning(false); }
};
const totalExpenses = sumBy(expenses, (e) =>
e.type === 'income' ? 0 : Number(e.amount) || 0,
);
const totalIncome = sumBy(expenses, (e) =>
e.type === 'income' ? Number(e.amount) || 0 : 0,
);
return (
<View
style={[
base.content,
{ backgroundColor: theme.background },
]}
>
<ScrollView
style={{ flex: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* ИИ-проверка чеков */}
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<View style={{ flex: 1, paddingRight: 6 }}>
<Text style={[base.cardTitle, { color: theme.text }]}>
ИИ-проверка чеков
</Text>
<Text style={[base.tinyText, { color: theme.textMuted, marginTop: 2 }]}>
Сделайте фото чека — в полноценном приложении сумма будет распознана автоматически. В демо на Snack сумму нужно вводить вручную.
</Text>
</View>
<View style={[base.pill, { backgroundColor: theme.accentSoft }]}>
<Text style={[base.pillText, { color: theme.accent }]}>🤖 Чек-сканер</Text>
</View>
</View>
text<TouchableOpacity onPress={handleScanReceipt} disabled={isScanning} style={[ base.button, { backgroundColor: isScanning ? theme.cardAlt : theme.accent, marginTop: 8, flexDirection: 'row', }, ]} > {isScanning && ( <ActivityIndicator size="small" style={{ marginRight: 8 }} color="#FFFFFF" /> )} <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {isScanning ? 'Сканируем чек...' : 'Сканировать чек с ИИ'} </Text> </TouchableOpacity> {lastReceiptPreview && ( <View style={[base.row, { marginTop: 10 }]}> <Image source={{ uri: lastReceiptPreview.uri }} style={{ width: 56, height: 56, borderRadius: 12, marginRight: 8 }} /> <View style={{ flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Последний чек (OCR) </Text> <Text numberOfLines={3} style={[base.smallText, { color: theme.text, marginTop: 2 }]} > {lastReceiptPreview.text || 'Текст не распознан'} </Text> </View> </View> )} </View> {/* Ручное добавление записи */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Новая запись</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}> Доход </Text> <Switch value={isIncome} onValueChange={setIsIncome} thumbColor={isIncome ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Магазин / описание" placeholderTextColor={theme.textMuted} value={title} onChangeText={(text) => { setTitle(text); if (!category) { setCategory(guessCategory(text)); } }} style={[base.inputInline, { color: theme.text }]} /> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} keyboardType="numeric" value={amount} onChangeText={setAmount} style={[base.inputInline, { color: theme.text }]} /> <Text style={[base.smallText, { color: theme.textMuted }]}>{currency}</Text> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Категория</Text> <ScrollView horizontal showsHorizontalScrollIndicator={false}> {DEFAULT_CATEGORIES.map((c) => ( <TouchableOpacity key={c} onPress={() => setCategory(c)} style={[ base.tag, { backgroundColor: category === c ? theme.accentSoft : theme.cardAlt, borderWidth: 1, borderColor: category === c ? theme.accent : theme.border, }, ]} > <Text style={[ base.tagText, { color: category === c ? theme.accent : theme.textMuted, }, ]} > {c} </Text> </TouchableOpacity> ))} </ScrollView> </View> <TouchableOpacity onPress={handleAdd} style={[ base.button, { backgroundColor: theme.accent, marginTop: 12 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить </Text> </TouchableOpacity> </View> {/* Итоги */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.rowBetween}> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего расходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalExpenses, currency)} </Text> </View> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего доходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalIncome, currency)} </Text> </View> </View> </View> {/* История */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>История</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> {expenses.length} записей </Text> </View> {expenses.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Добавьте первую трату или доход. </Text> ) : ( <FlatList data={expenses} keyExtractor={(item) => item.id} scrollEnabled={false} renderItem={({ item }) => { const isImpulseHighlight = item.type !== 'income' && antiImpulseEnabled && antiImpulseThreshold > 0 && Number(item.amount) >= antiImpulseThreshold; const isReceipt = item.meta && item.meta.source === 'receipt'; return ( <View> <View style={base.expenseItem}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> {item.title} </Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted }]}> {formatDate(item.date)} </Text> <View style={[ base.tag, { backgroundColor: theme.cardAlt, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.textMuted }, ]} > {item.category || 'Другое'} </Text> </View> {isReceipt && ( <View style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > ИИ-чек </Text> </View> )} </View> </View> <Text style={[ base.smallText, { color: item.type === 'income' ? theme.success : isImpulseHighlight ? theme.danger : theme.text, }, ]} > {item.type === 'income' ? '+' : '-'} {formatCurrency(item.amount, currency)} </Text> </View> <View style={[ base.divider, { backgroundColor: theme.border }, ]} /> </View> ); }} /> )} </View> </ScrollView> </View>
);
};
const GoalsScreen = ({ userData, onUpdateUserData, theme }) => {
const [title, setTitle] = useState('');
const [target, setTarget] = useState('');
const [months, setMonths] = useState('');
const [whatIfCafe, setWhatIfCafe] = useState('30');
const [whatIfSave, setWhatIfSave] = useState('10000');
const [goalImage, setGoalImage] = useState(null);
const [editGoalId, setEditGoalId] = useState(null);
const [editTitle, setEditTitle] = useState('');
const [editTarget, setEditTarget] = useState('');
const [editDeadlineMonths, setEditDeadlineMonths] = useState('');
const goals = userData.goals || [];
const settings = userData.settings || {};
const currency = settings.currency || DEFAULT_CURRENCY;
const handleAddGoal = () => {
const t = parseFloat(String(target).replace(',', '.'));
const m = parseInt(months, 10);
if (!title || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) {
Alert.alert('Ошибка', 'Укажите цель, сумму и срок в месяцах.');
return;
}
const deadline = new Date();
deadline.setMonth(deadline.getMonth() + m);
const newGoal = {
id: goal_${Date.now()},
title: title.trim(),
targetAmount: t,
savedAmount: 0,
deadline: deadline.toISOString(),
imageUri: goalImage || null,
};
onUpdateUserData((prev) => ({
...prev,
goals: [...(prev.goals || []), newGoal],
}));
setTitle('');
setTarget('');
setMonths('');
setGoalImage(null);
};
const handleDeposit = (goalId, amount) => {
const n = amount || 0;
onUpdateUserData((prev) => ({
...prev,
goals: (prev.goals || []).map((g) =>
g.id === goalId
? { ...g, savedAmount: (Number(g.savedAmount) || 0) + n }
: g,
),
expenses: [
...(prev.expenses || []),
{
id: save_${Date.now()},
title: 'Перевод в копилку',
amount: n,
category: 'Сбережения',
type: 'expense',
date: new Date().toISOString(),
},
],
}));
};
const handlePickGoalImage = async () => {
const uri = await pickImageFromLibrary();
if (uri) setGoalImage(uri);
};
const handleEditGoalPress = (goal) => {
const now = new Date();
const deadline = goal.deadline ? new Date(goal.deadline) : null;
const monthsLeft =
deadline && !Number.isNaN(deadline.getTime())
? Math.max(
1,
Math.round(
(deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30),
),
)
: '';
textsetEditGoalId(goal.id); setEditTitle(goal.title); setEditTarget(String(goal.targetAmount || '')); setEditDeadlineMonths(monthsLeft ? String(monthsLeft) : '');
};
const handleSaveEditedGoal = () => {
if (!editGoalId) return;
const t = parseFloat(String(editTarget).replace(',', '.'));
const m = parseInt(editDeadlineMonths, 10);
if (!editTitle || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) {
Alert.alert('Ошибка', 'Укажите корректные данные для цели.');
return;
}
const newDeadline = new Date();
newDeadline.setMonth(newDeadline.getMonth() + m);
textonUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).map((g) => g.id === editGoalId ? { ...g, title: editTitle.trim(), targetAmount: t, deadline: newDeadline.toISOString(), } : g, ), })); setEditGoalId(null); setEditTitle(''); setEditTarget(''); setEditDeadlineMonths('');
};
const handleCancelEdit = () => {
setEditGoalId(null);
setEditTitle('');
setEditTarget('');
setEditDeadlineMonths('');
};
const handleDeleteGoal = (goalId) => {
Alert.alert(
'Удалить цель',
'Вы уверены, что хотите удалить эту цель? Прогресс по ней будет потерян.',
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Удалить',
style: 'destructive',
onPress: () => {
onUpdateUserData((prev) => ({
...prev,
goals: (prev.goals || []).filter((g) => g.id !== goalId),
}));
},
},
],
);
};
const now = new Date();
const totalSavedAll = sumBy(goals, (g) => Number(g.savedAmount) || 0);
const totalTargetAll = sumBy(goals, (g) => Number(g.targetAmount) || 0);
const cafeReduce = parseFloat(String(whatIfCafe).replace(',', '.')) || 0;
const extraWeekly = parseFloat(String(whatIfSave).replace(',', '.')) || 0;
const cafeCurrent = sumBy(userData.expenses || [], (e) => {
if (e.type === 'income') return 0;
const c = (e.category || '').toLowerCase();
if (c.includes('еда') || c.includes('кафе')) {
const d = new Date(e.date || new Date());
const key = getMonthKey(d);
if (key === getMonthKey(now)) {
return Number(e.amount) || 0;
}
}
return 0;
});
const cafePotential = cafeCurrent * (cafeReduce / 100);
const whatIfMonthlyExtra = cafePotential + extraWeekly * 4;
return (
<ScrollView
style={[
base.content,
{ backgroundColor: theme.background },
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Блок создания цели */}
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<Text style={[base.cardTitle, { color: theme.text }]}>Цели и копилки</Text>
<View style={[base.pill, { backgroundColor: theme.accentSoft }]}>
<Text style={[base.pillText, { color: theme.accent }]}>💎 Планы</Text>
</View>
</View>
<Text style={[base.tinyText, { color: theme.textMuted }]}>
Всего накоплено: {formatCurrency(totalSavedAll, currency)} из{' '}
{formatCurrency(totalTargetAll || 0, currency)}
</Text>
text<View style={{ marginTop: 12 }}> <TouchableOpacity onPress={handlePickGoalImage} style={[ base.row, { marginBottom: 8, }, ]} > {goalImage ? ( <Image source={{ uri: goalImage }} style={[base.goalImage, { width: 80, height: 80 }]} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}>📷</Text> </View> )} <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото цели (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте визуал — так легче держаться плана. </Text> </View> </TouchableOpacity> <TextInput placeholder="Название цели (например, «Ноутбук»)" placeholderTextColor={theme.textMuted} value={title} onChangeText={setTitle} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={target} onChangeText={setTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={months} onChangeText={setMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <TouchableOpacity onPress={handleAddGoal} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить цель </Text> </TouchableOpacity> </View> </View> {/* Список целей с редактированием и удалением */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Мои цели</Text> </View> {goals.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Создайте первую цель, чтобы следить за прогрессом. </Text> ) : ( goals.map((g) => { const targetAmount = Number(g.targetAmount) || 0; const savedAmount = Number(g.savedAmount) || 0; const pct = targetAmount ? Math.min(100, (savedAmount / targetAmount) * 100) : 0; const deadline = g.deadline ? new Date(g.deadline) : null; const daysLeft = deadline && !Number.isNaN(deadline.getTime()) ? Math.round( (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), ) : null; const left = Math.max(0, targetAmount - savedAmount); const perWeek = daysLeft && daysLeft > 0 ? (left / daysLeft) * 7 : left / 4 || 0; const isEditing = editGoalId === g.id; return ( <View key={g.id} style={{ marginBottom: 12 }}> <View style={base.rowBetween}> <Text style={[base.smallText, { color: theme.text, maxWidth: '70%' }]}> {g.title} </Text> <View style={base.row}> <TouchableOpacity onPress={() => handleEditGoalPress(g)}> <Text style={[base.tinyText, { color: theme.accent, marginRight: 12 }]}> Изм. </Text> </TouchableOpacity> <TouchableOpacity onPress={() => handleDeleteGoal(g.id)}> <Text style={[base.tinyText, { color: theme.danger }]}> Удалить </Text> </TouchableOpacity> </View> </View> {g.imageUri ? ( <Image source={{ uri: g.imageUri }} style={base.goalImage} /> ) : null} <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accent, width: `${pct}%`, }, ]} /> </View> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> {formatCurrency(savedAmount, currency)} из{' '} {formatCurrency(targetAmount, currency)} </Text> {daysLeft !== null && ( <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 4 }, ]} > Осталось ~{daysLeft} дн. </Text> )} </View> {left > 0 && ( <View style={base.rowBetween}> <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 2 }, ]} > Чтобы успеть: ~{formatCurrency(perWeek, currency)} в неделю </Text> <TouchableOpacity onPress={() => handleDeposit(g.id, 10000)} style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 2, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > +10 000 в копилку </Text> </TouchableOpacity> </View> )} {isEditing && ( <View style={{ marginTop: 8, padding: 10, borderRadius: 12, backgroundColor: theme.cardAlt, }} > <Text style={[base.tinyText, { color: theme.textMuted, marginBottom: 4 }]}> Редактирование цели </Text> <TextInput placeholder="Название цели" placeholderTextColor={theme.textMuted} value={editTitle} onChangeText={setEditTitle} style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={editTarget} onChangeText={setEditTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={editDeadlineMonths} onChangeText={setEditDeadlineMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={base.rowBetween}> <TouchableOpacity onPress={handleSaveEditedGoal} style={[ base.button, { flex: 1, backgroundColor: theme.accent, marginTop: 4, marginRight: 4, }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить </Text> </TouchableOpacity> <TouchableOpacity onPress={handleCancelEdit} style={[ base.button, { flex: 1, backgroundColor: theme.card, marginTop: 4, marginLeft: 4, borderWidth: 1, borderColor: theme.border, }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Отмена </Text> </TouchableOpacity> </View> </View> )} <View style={[ base.divider, { backgroundColor: theme.border, marginTop: 8 }, ]} /> </View> ); }) )} </View> {/* Блок «Что если?» */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>«Что если?»</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧪 Симулятор</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Смоделируйте изменения привычек и посмотрите влияние на накопления. </Text> <View style={{ marginTop: 8 }}> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> На сколько % сократить кафе/еду? </Text> <TextInput placeholder="30" placeholderTextColor={theme.textMuted} value={whatIfCafe} onChangeText={setWhatIfCafe} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Доп. отложить в неделю </Text> <TextInput placeholder="10 000" placeholderTextColor={theme.textMuted} value={whatIfSave} onChangeText={setWhatIfSave} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.smallText, { color: theme.text }]}> При таком сценарии вы сможете дополнительно накопить около{' '} {formatCurrency(whatIfMonthlyExtra, currency)} в месяц. </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> За год это будет примерно{' '} {formatCurrency(whatIfMonthlyExtra * 12, currency)} — почти целая отдельная цель 💡 </Text> </View> </View> </View> </ScrollView>
);
};
const AiChatScreen = ({ userData, theme }) => {
const [messages, setMessages] = useState([
{
id: 'm_hello',
from: 'ai',
text: 'Привет! Я твой финансовый ИИ-ассистент. Задавай вопросы: «Как сократить расходы на 20%?», «Как распределить зарплату?», «Что будет, если начать откладывать больше?»',
},
]);
const [input, setInput] = useState('');
const [voiceOn, setVoiceOn] = useState(false);
const sendText = (text) => {
const trimmed = text.trim();
if (!trimmed) return;
const userMsg = {
id: m_${Date.now()}_u,
from: 'user',
text: trimmed,
};
const aiText = generateAiAnswer(trimmed, userData);
const aiMsg = {
id: m_${Date.now()}_ai,
from: 'ai',
text: aiText,
};
setMessages((prev) => [...prev, userMsg, aiMsg]);
setInput('');
if (voiceOn) {
Speech.stop();
Speech.speak(aiText, { language: 'ru-RU' });
}
};
const handleSend = () => {
sendText(input);
};
const quickPrompts = [
'Как сократить расходы на 20%?',
'Как распределить зарплату 300 000?',
'Стоит ли покупать новый телефон?',
'Сделай обзор моих трат.',
];
return (
<KeyboardAvoidingView
style={[
base.content,
{ backgroundColor: theme.background },
]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<Text style={[base.cardTitle, { color: theme.text }]}>ИИ-консультант</Text>
<View style={base.row}>
<Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}>
Голос
</Text>
<Switch
value={voiceOn}
onValueChange={setVoiceOn}
thumbColor={voiceOn ? theme.accent : '#9CA3AF'}
trackColor={{ true: theme.accentSoft, false: theme.cardAlt }}
/>
</View>
</View>
<Text style={[base.tinyText, { color: theme.textMuted }]}>
Ответы основаны на ваших реальных данных: расходах, доходах, целях.
</Text>
<View
style={{
marginTop: 8,
flexDirection: 'row',
flexWrap: 'wrap',
}}
>
{quickPrompts.map((p) => (
<TouchableOpacity
key={p}
onPress={() => sendText(p)}
style={[
base.badge,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.tinyText, { color: theme.text }]}>{p}</Text>
</TouchableOpacity>
))}
</View>
</View>
text<View style={[ base.card, { flex: 1, backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 8 }} showsVerticalScrollIndicator={false} > {messages.map((m) => { const isUser = m.from === 'user'; return ( <View key={m.id} style={[ base.chatBubble, isUser ? base.chatUser : base.chatAi, { backgroundColor: isUser ? theme.accent : theme.cardAlt, }, ]} > <Text style={[ base.tinyText, { color: isUser ? '#FFFFFF' : theme.textMuted }, ]} > {isUser ? 'Вы' : 'ИИ'} </Text> <Text style={[ base.smallText, { color: isUser ? '#FFFFFF' : theme.text, marginTop: 2, }, ]} > {m.text} </Text> </View> ); })} </ScrollView> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, marginTop: 4, }, ]} > <TextInput placeholder="Задайте вопрос по финансам..." placeholderTextColor={theme.textMuted} value={input} onChangeText={setInput} style={[base.inputInline, { color: theme.text }]} multiline /> <TouchableOpacity onPress={handleSend}> <Text style={[base.smallText, { color: theme.accent }]}>Отпр.</Text> </TouchableOpacity> </View> </View> </KeyboardAvoidingView>
);
};
const ProfileScreen = ({
currentUser,
userData,
onUpdateUserData,
theme,
onLogout,
onToggleTheme,
}) => {
const profile = userData.profile || {};
const [firstName, setFirstName] = useState(profile.firstName || '');
const [lastName, setLastName] = useState(profile.lastName || '');
const [phone, setPhone] = useState(profile.phone || '');
const [birthDate, setBirthDate] = useState(profile.birthDate || '');
const [avatarUri, setAvatarUri] = useState(profile.avatarUri || '');
const [monthlyIncome, setMonthlyIncome] = useState(
String((userData.settings && userData.settings.monthlyIncome) || ''),
);
const [weeklyBudget, setWeeklyBudget] = useState(
String((userData.settings && userData.settings.weeklyBudget) || ''),
);
const [currency, setCurrency] = useState(
(userData.settings && userData.settings.currency) || DEFAULT_CURRENCY,
);
const [antiImpulseEnabled, setAntiImpulseEnabled] = useState(
(userData.settings && userData.settings.antiImpulseEnabled) || false,
);
const [antiImpulseThreshold, setAntiImpulseThreshold] = useState(
String((userData.settings && userData.settings.antiImpulseThreshold) || ''),
);
const [pinInput, setPinInput] = useState('');
const [noteDraft, setNoteDraft] = useState('');
const [noteUnlocked, setNoteUnlocked] = useState(false);
useEffect(() => {
const savedEncrypted = userData.secureNote || '';
if (savedEncrypted && currentUser.pin) {
const dec = decryptNote(savedEncrypted, currentUser.pin);
setNoteDraft(dec);
} else if (savedEncrypted && !currentUser.pin) {
setNoteDraft(savedEncrypted);
} else {
setNoteDraft('');
}
}, [userData.secureNote, currentUser.pin]);
useEffect(() => {
const p = userData.profile || {};
setFirstName(p.firstName || '');
setLastName(p.lastName || '');
setPhone(p.phone || '');
setBirthDate(p.birthDate || '');
setAvatarUri(p.avatarUri || '');
}, [userData.profile]);
const handleSaveSettings = () => {
const m = parseFloat(String(monthlyIncome).replace(',', '.')) || 0;
const w = parseFloat(String(weeklyBudget).replace(',', '.')) || 0;
const anti =
parseFloat(String(antiImpulseThreshold).replace(',', '.')) || 0;
textonUpdateUserData((prev) => ({ ...prev, income: m, settings: { ...(prev.settings || {}), monthlyIncome: m, weeklyBudget: w, currency: currency || DEFAULT_CURRENCY, antiImpulseEnabled, antiImpulseThreshold: anti, }, profile: { ...(prev.profile || {}), firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), birthDate: birthDate.trim(), avatarUri: avatarUri || '', }, })); Alert.alert('Сохранено', 'Профиль и бюджет обновлены.');
};
const handleUnlockNote = () => {
if (!currentUser.pin) {
setNoteUnlocked(true);
return;
}
if (pinInput === currentUser.pin) {
const dec = decryptNote(userData.secureNote || '', currentUser.pin);
setNoteDraft(dec);
setNoteUnlocked(true);
} else {
Alert.alert('Ошибка', 'Неверный PIN.');
}
};
const handleSaveNote = () => {
let encrypted = noteDraft;
if (currentUser.pin) {
encrypted = encryptNote(noteDraft, currentUser.pin);
}
onUpdateUserData((prev) => ({
...prev,
secureNote: encrypted,
}));
Alert.alert('Сохранено', 'Заметка зашифрована и сохранена.');
};
const totalSubs = (userData.subscriptions || []).length;
const handlePickAvatar = async () => {
const uri = await pickImageFromLibrary();
if (uri) setAvatarUri(uri);
};
const displayName =
firstName || lastName
? ${firstName} ${lastName}.trim()
: currentUser.email;
return (
<ScrollView
style={[
base.content,
{ backgroundColor: theme.background },
]}
showsVerticalScrollIndicator={false}
>
{/* Профиль и быстрый статус */}
<View
style={[
base.card,
{ backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
]}
>
<View style={base.cardHeaderRow}>
<View style={base.row}>
<TouchableOpacity onPress={handlePickAvatar}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={base.avatarImage} />
) : (
<View
style={[
base.avatarCircle,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.smallText, { color: theme.textMuted }]}>
{firstName
? firstName.charAt(0).toUpperCase()
: currentUser.email.charAt(0).toUpperCase()}
</Text>
</View>
)}
</TouchableOpacity>
<View style={{ marginLeft: 10 }}>
<Text style={[base.cardTitle, { color: theme.text }]}>
{displayName || 'Пользователь'}
</Text>
<Text style={[base.tinyText, { color: theme.textMuted }]}>
{currentUser.email}
</Text>
</View>
</View>
<TouchableOpacity onPress={onLogout}>
<Text style={[base.smallText, { color: theme.danger }]}>Выйти</Text>
</TouchableOpacity>
</View>
<Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}>
Подписок учтено: {totalSubs} • PIN установлен:{' '}
{currentUser.pin ? 'да' : 'нет'}
</Text>
text<View style={{ marginTop: 12 }}> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} value={phone} onChangeText={setPhone} keyboardType="phone-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> {/* Бюджет и валюта */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Бюджет</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Установите доход, недельный бюджет и валюту — это нужно для индекса «финансовое здоровье» и рекомендаций. </Text> <TextInput placeholder="Доход в месяц" placeholderTextColor={theme.textMuted} value={monthlyIncome} onChangeText={setMonthlyIncome} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Недельный бюджет" placeholderTextColor={theme.textMuted} value={weeklyBudget} onChangeText={setWeeklyBudget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Валюта (например, ₸, ₽, $)" placeholderTextColor={theme.textMuted} value={currency} onChangeText={setCurrency} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TouchableOpacity onPress={handleSaveSettings} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить профиль и бюджет </Text> </TouchableOpacity> </View> {/* Тема */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Тема и вид</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 6 }]}> Тёмная тема </Text> <Switch value={theme.mode === 'dark'} onValueChange={onToggleTheme} thumbColor={theme.mode === 'dark' ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> В будущем сюда можно добавить иконки, виджеты и другие настройки UI. </Text> </View> {/* Анти-импульсная защита */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Контроль импульсных покупок</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧠 Эксперимент</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Включите анти-импульсный режим, чтобы приложение задавало вам «контрольный вопрос» перед крупной покупкой. </Text> <View style={[base.rowBetween, { marginTop: 8 }]}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> Анти-импульсный режим </Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Срабатывает при тратах выше порога. </Text> </View> <Switch value={antiImpulseEnabled} onValueChange={setAntiImpulseEnabled} thumbColor={antiImpulseEnabled ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> <TextInput placeholder="Порог крупной покупки (например, 50 000)" placeholderTextColor={theme.textMuted} value={antiImpulseThreshold} onChangeText={setAntiImpulseThreshold} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> </View> {/* Безопасная заметка */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Безопасная заметка</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔐 Локально</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Заметка шифруется простым алгоритмом (XOR + HEX) с вашим PIN и хранится только в AsyncStorage. Для реального проекта используйте надёжную криптографию (AES и т.п.) и биометрию. </Text> {!noteUnlocked && ( <> {currentUser.pin ? ( <> <TextInput placeholder="Введите PIN для разблокировки" placeholderTextColor={theme.textMuted} value={pinInput} onChangeText={setPinInput} keyboardType="number-pad" secureTextEntry style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleUnlockNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Разблокировать заметку </Text> </TouchableOpacity> </> ) : ( <> <Text style={[base.smallText, { color: theme.text, marginTop: 8 }]}> PIN не установлен при регистрации, поэтому заметка хранится без шифрования. </Text> <TouchableOpacity onPress={() => setNoteUnlocked(true)} style={[ base.button, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Показать заметку </Text> </TouchableOpacity> </> )} </> )} {noteUnlocked && ( <> <TextInput placeholder="Секретная финансовая заметка (пароли сюда лучше не писать)" placeholderTextColor={theme.textMuted} value={noteDraft} onChangeText={setNoteDraft} multiline style={[ base.input, { minHeight: 80, textAlignVertical: 'top', backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleSaveNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить зашифровано </Text> </TouchableOpacity> </> )} </View> </ScrollView>
);
};
const MainApp = ({
currentUser,
userData,
setUserData,
onLogout,
themeMode,
setThemeMode,
}) => {
const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME;
const [activeTab, setActiveTab] = useState('home');
const handleUpdateUserData = (updater) => {
setUserData((prev) => {
const prevVal = prev || createDefaultUserData(themeMode);
const next = typeof updater === 'function' ? updater(prevVal) : updater;
return next;
});
};
const handleToggleTheme = () => {
setThemeMode((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
const profile = userData.profile || {};
const avatarUri = profile.avatarUri;
const displayName =
profile.firstName || profile.lastName
? ${profile.firstName || ''} ${profile.lastName || ''}.trim()
: currentUser.email;
const initials =
profile.firstName || profile.lastName
? ${(profile.firstName || profile.lastName || 'U').charAt(0).toUpperCase()}
: currentUser.email.charAt(0).toUpperCase();
return (
<SafeAreaView
style={[
base.container,
{ backgroundColor: theme.background },
]}
>
<StatusBar
barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'}
/>
<View style={base.header}>
<View style={base.row}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={base.avatarImage} />
) : (
<View
style={[
base.avatarCircle,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.smallText, { color: theme.textMuted }]}>
{initials}
</Text>
</View>
)}
<View style={{ marginLeft: 10 }}>
<Text style={[base.headerTitle, { color: theme.text }]}>
Мой бюджет
</Text>
<Text style={[base.headerSubtitle, { color: theme.textMuted }]}>
{displayName}
</Text>
</View>
</View>
<View style={base.chipRow}>
<View
style={[
base.chip,
{ backgroundColor: theme.accentSoft },
]}
>
<Text style={[base.chipText, { color: theme.accent }]}>🤖 ИИ-чек</Text>
</View>
<TouchableOpacity
onPress={handleToggleTheme}
style={[
base.chip,
{ backgroundColor: theme.cardAlt },
]}
>
<Text style={[base.chipText, { color: theme.text }]}>
{theme.mode === 'dark' ? '◐' : '◑'}
</Text>
</TouchableOpacity>
</View>
</View>
text{activeTab === 'home' && <HomeScreen userData={userData} theme={theme} />} {activeTab === 'expenses' && ( <ExpensesScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'goals' && ( <GoalsScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'ai' && <AiChatScreen userData={userData} theme={theme} />} {activeTab === 'profile' && ( <ProfileScreen currentUser={currentUser} userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} onLogout={onLogout} onToggleTheme={handleToggleTheme} /> )} <View style={[ base.tabBar, { backgroundColor: theme.card, borderTopColor: theme.border }, ]} > {[ { id: 'home', label: 'Главная', icon: '🏡' }, { id: 'expenses', label: 'Траты', icon: '💳' }, { id: 'goals', label: 'Цели', icon: '📌' }, { id: 'ai', label: 'ИИ-чат', icon: '🧠' }, { id: 'profile', label: 'Профиль', icon: '👤' }, ].map((tab) => { const isActive = activeTab === tab.id; return ( <TouchableOpacity key={tab.id} onPress={() => setActiveTab(tab.id)} style={[ base.tabButton, { backgroundColor: isActive ? theme.accentSoft : 'transparent' }, ]} > <Text style={[ { fontSize: 16 }, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.icon} </Text> <Text style={[ base.tabLabel, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.label} </Text> </TouchableOpacity> ); })} </View> </SafeAreaView>
);
};
// ---------- корневой компонент ----------
export default function App() {
const [users, setUsers] = useState([]);
const [session, setSession] = useState(null);
const [userData, setUserData] = useState(null);
const [themeMode, setThemeMode] = useState('dark');
const [loading, setLoading] = useState(true);
const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME;
useEffect(() => {
const init = async () => {
try {
const usersJson = await AsyncStorage.getItem(STORAGE_KEYS.USERS);
const sessionJson = await AsyncStorage.getItem(STORAGE_KEYS.SESSION);
const loadedUsers = usersJson ? JSON.parse(usersJson) : [];
setUsers(loadedUsers);
if (sessionJson) {
const s = JSON.parse(sessionJson);
const u = loadedUsers.find((user) => user.id === s.userId);
if (u) {
setSession(s);
const dataKey = FINAPP_DATA_${u.id};
const dataJson = await AsyncStorage.getItem(dataKey);
if (dataJson) {
const parsed = JSON.parse(dataJson);
setUserData(parsed);
if (parsed.settings && parsed.settings.themeMode) {
setThemeMode(parsed.settings.themeMode);
}
} else {
const defaultData = createDefaultUserData(themeMode);
setUserData(defaultData);
await AsyncStorage.setItem(dataKey, JSON.stringify(defaultData));
}
}
}
} catch (e) {
console.warn('Init error', e);
} finally {
setLoading(false);
}
};
init();
}, []);
useEffect(() => {
const persist = async () => {
if (!session || !userData) return;
try {
const key = FINAPP_DATA_${session.userId};
const payload = {
...userData,
settings: {
...(userData.settings || {}),
themeMode,
},
};
await AsyncStorage.setItem(key, JSON.stringify(payload));
} catch (e) {
console.warn('Save userData error', e);
}
};
persist();
}, [userData, session, themeMode]);
const handleLogin = async (user, profileFromRegistration = null) => {
const s = { userId: user.id };
setSession(s);
await AsyncStorage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(s));
const key = FINAPP_DATA_${user.id};
const dataJson = await AsyncStorage.getItem(key);
if (dataJson) {
const parsed = JSON.parse(dataJson);
setUserData(parsed);
if (parsed.settings && parsed.settings.themeMode) {
setThemeMode(parsed.settings.themeMode);
}
} else {
const defaultData = createDefaultUserData(themeMode, profileFromRegistration || {});
setUserData(defaultData);
await AsyncStorage.setItem(key, JSON.stringify(defaultData));
}
};
const handleRegister = async (newUser, profile) => {
const updatedUsers = [...users, newUser];
setUsers(updatedUsers);
await AsyncStorage.setItem(STORAGE_KEYS.USERS, JSON.stringify(updatedUsers));
await handleLogin(newUser, profile);
};
const handleLogout = async () => {
setSession(null);
setUserData(null);
await AsyncStorage.removeItem(STORAGE_KEYS.SESSION);
};
if (loading) {
return (
<SafeAreaView
style={[
base.container,
base.center,
{ backgroundColor: theme.background },
]}
>
<StatusBar
barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'}
/>
<Text style={[base.smallText, { color: theme.textMuted }]}>
Загрузка данных...
</Text>
</SafeAreaView>
);
}
if (!session || !userData) {
return (
<AuthScreen
users={users}
onLogin={handleLogin}
onRegister={handleRegister}
theme={theme}
/>
);
}
const currentUser = users.find((u) => u.id === session.userId) || {
id: session.userId,
email: 'user',
pin: '',
};
return (
<MainApp
currentUser={currentUser}
userData={userData}
setUserData={setUserData}
onLogout={handleLogout}
themeMode={themeMode}
setThemeMode={setThemeMode}
/>
);
} возникает ошибка Cannot find native module 'ExpoMlkitOcr'
Expo Snack:
❌ НЕ поддерживает нативные модули
Snack работает ТОЛЬКО на чистом JavaScript.
А библиотека:
expo-mlkit-ocr
требует:
нативный Android код
нативный iOS код
Expo Dev Client
expo prebuild
сборку через Xcode / Android Studio
Snack таких модулей НЕ СБИРАЕТ — поэтому:
Cannot find native module 'ExpoMlkitOcr'
Failed to resolve dependency 'expo-modules-core'
Это нормальная ошибка для Snack.
Ты никак её не исправишь внутри Snack, потому что Snack физически не может собрать нативные библиотеки. нужно переделать твой код под Snack (БЕЗ ML Kit)
Да, всё, что ты описал про Snack и expo-mlkit-ocr, — ровно так и есть. Snack физически не умеет собирать нативные модули, поэтому эта ошибка там нормальная и «починить» сам модуль внутри Snack нельзя. Но можно сделать так, чтобы твой код в Snack вообще не пытался его использовать — тогда ошибки не будет.
Сейчас у тебя код уже почти правильно переделан, но, судя по ошибке, где-то всё ещё торчит expo-mlkit-ocr.
Разберёмся по шагам, что нужно сделать, чтобы Snack работал без ошибок 👇
expo-mlkit-ocr из зависимостейОткрой Snack:
expo-mlkit-ocr.Пока эта зависимость есть, Metro будет пытаться её подтянуть, а она, в свою очередь, тянет expo-modules-core и нативный модуль → получаешь:
Cannot find native module 'ExpoMlkitOcr'Failed to resolve dependency 'expo-modules-core'Для демо в Snack — expo-mlkit-ocr вообще не нужен.
expo-mlkit-ocrВ твоём коде уже есть правильный комментарий:
js// ВАЖНО: expo-mlkit-ocr не работает в Snack / Expo Go, поэтому не импортируем его // import ExpoMlkitOcr from 'expo-mlkit-ocr';
Главное — чтобы ниже нигде не оставалось:
jsimport ExpoMlkitOcr from 'expo-mlkit-ocr';
или
jsconst ExpoMlkitOcr = require('expo-mlkit-ocr');
Если хотя бы одна такая строка есть — Snack попытается подхватить модуль и снова упадёт.
runOcrOnReceiptImage — это как раз то, что нужно для SnackУ тебя уже есть идеальная заглушка:
jsconst runOcrOnReceiptImage = async (uri) => { console.log('runOcrOnReceiptImage: stub on Snack, uri:', uri); if (Platform.OS === 'web') { Alert.alert( 'OCR недоступен', 'Распознавание чеков не поддерживается в веб-версии Snack. Введите сумму вручную.', ); } else { Alert.alert( 'OCR недоступен в Snack', 'Для сканирования чеков через ML Kit нужен кастомный dev-клиент. В демо на Snack сумму нужно вводить вручную.', ); } // Возвращаем текст, чтобы показать превью, но сумма из него не извлечётся return '(OCR недоступен в Snack — введите сумму вручную)'; };
И в handleScanReceipt ты правильно его используешь:
jsconst ocrText = await runOcrOnReceiptImage(uri); setLastReceiptPreview({ uri, text: ocrText }); const parsed = parseReceiptOcrText(ocrText); if (!parsed || !parsed.amount || parsed.amount <= 0) { Alert.alert( 'Не удалось распознать чек', 'На Snack распознавание суммы недоступно. Введите сумму вручную в блоке «Новая запись».', ); return; }
➡️ Для Snack этого достаточно. Он делает фото, показывает превью, но не пытается вызвать ML Kit — значит, никакого нативного модуля не требуется.
Если всё выше выполнено (зависимость удалена + импортов нет), то ошибка про ExpoMlkitOcr пропадёт.
Обычно делают так:
В реальном проекте (EAS / dev-client):
expo-mlkit-ocr в зависимости,runOcrOnReceiptImage с ML Kit.В Snack:
expo-mlkit-ocr не добавляешь,Например, для реального проекта (НЕ для Snack) функция может выглядеть так:
js// ⚠️ ЭТО ДЛЯ НАСТОЯЩЕГО ПРОЕКТА, НЕ ДЛЯ SNACK import * as ImageManipulator from 'expo-image-manipulator'; import ExpoMlkitOcr from 'expo-mlkit-ocr'; const runOcrOnReceiptImage = async (uri) => { // иногда полезно уменьшить картинку перед OCR const resized = await ImageManipulator.manipulateAsync( uri, [{ resize: { width: 1200 } }], { compress: 0.8, format: ImageManipulator.SaveFormat.JPEG } ); const result = await ExpoMlkitOcr.scanFromURLAsync(resized.uri, { languageHints: ['ru', 'en'], }); const text = result.blocks?.map((b) => b.text).join('\n') || ''; return text; };
Но в Snack этот код запускать нельзя — там оставляем твой stub.
В Snack всё должно быть так:
expo-mlkit-ocr.import ExpoMlkitOcr from 'expo-mlkit-ocr'.runOcrOnReceiptImage со stub + Alert.handleScanReceipt использует этот stub (как у тебя сейчас).Если все четыре пункта соблюдены — проект в Snack запускается без ошибок про ExpoMlkitOcr, а пользователю показывается честное сообщение «OCR недоступен в Snack — введите сумму вручную».
Если хочешь, я могу:
отправь мне полный рабочий код в snack expo dev
ладно уберем это распознование текста просто убери и отправь мой изначальный код без него отправь мне весь код без распознавание чека полностью
Вот полный код без распознавания чеков и без кнопки «Сканировать чек». Всё остальное (ИИ-чат, цели, профиль, тема и т.д.) оставлено как было.
jsimport React, { useEffect, useState } from 'react'; import { SafeAreaView, View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView, FlatList, StatusBar, KeyboardAvoidingView, Platform, Switch, Alert, Image, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as ImagePicker from 'expo-image-picker'; import * as Speech from 'expo-speech'; // ---------- константы ---------- const STORAGE_KEYS = { USERS: 'FINAPP_USERS', SESSION: 'FINAPP_SESSION', }; const DARK_THEME = { mode: 'dark', background: '#020617', card: '#020617', cardAlt: '#0B1220', accent: '#22C55E', accentSoft: 'rgba(34,197,94,0.22)', text: '#F9FAFB', textMuted: '#9CA3AF', border: '#1F2937', danger: '#EF4444', success: '#22C55E', warning: '#EAB308', }; const LIGHT_THEME = { mode: 'light', background: '#F3F4F6', card: '#FFFFFF', cardAlt: '#E5E7EB', accent: '#0F766E', accentSoft: 'rgba(15,118,110,0.16)', text: '#111827', textMuted: '#6B7280', border: '#D1D5DB', danger: '#DC2626', success: '#16A34A', warning: '#CA8A04', }; const DEFAULT_CURRENCY = '₸'; const DEFAULT_CATEGORIES = [ 'Еда', 'Транспорт', 'Жильё', 'Здоровье', 'Подписки', 'Игры/развлечения', 'Покупки', 'Сбережения', 'Другое', ]; // ---------- стили ---------- const base = StyleSheet.create({ flex1: { flex: 1 }, center: { justifyContent: 'center', alignItems: 'center' }, container: { flex: 1 }, authContainer: { flex: 1, paddingHorizontal: 24, paddingTop: 32, paddingBottom: 24 }, title: { fontSize: 28, fontWeight: '700', marginBottom: 4 }, subtitle: { fontSize: 14, marginBottom: 24 }, input: { borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, fontSize: 14, marginBottom: 12, }, button: { borderRadius: 18, paddingVertical: 12, alignItems: 'center', justifyContent: 'center', marginTop: 4, }, buttonText: { fontSize: 15, fontWeight: '600' }, link: { fontSize: 14, marginTop: 12, textAlign: 'center' }, errorText: { fontSize: 13, marginBottom: 8 }, header: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, headerTitle: { fontSize: 22, fontWeight: '700' }, headerSubtitle: { fontSize: 12 }, chipRow: { flexDirection: 'row', alignItems: 'center' }, chip: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999, marginLeft: 8, }, chipText: { fontSize: 12 }, content: { flex: 1, paddingHorizontal: 16, paddingTop: 4, paddingBottom: 4, }, card: { borderRadius: 20, padding: 16, marginBottom: 14, shadowColor: '#000', shadowOpacity: 0.14, shadowRadius: 12, shadowOffset: { width: 0, height: 4 }, elevation: 4, }, cardHeaderRow: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 6, }, cardTitle: { fontSize: 16, fontWeight: '600' }, cardSubtitle: { fontSize: 12 }, row: { flexDirection: 'row', alignItems: 'center' }, rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, pill: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, marginLeft: 6, }, pillText: { fontSize: 11, fontWeight: '500' }, tabBar: { flexDirection: 'row', paddingHorizontal: 6, paddingVertical: 8, borderTopWidth: StyleSheet.hairlineWidth, }, tabButton: { flex: 1, alignItems: 'center', paddingVertical: 8, borderRadius: 999, marginHorizontal: 4, }, tabLabel: { fontSize: 11, marginTop: 2 }, badgeDot: { width: 8, height: 8, borderRadius: 999, marginRight: 4, }, smallText: { fontSize: 12 }, tinyText: { fontSize: 11 }, sectionTitle: { fontSize: 14, fontWeight: '600', marginBottom: 8 }, inputRow: { flexDirection: 'row', alignItems: 'center', borderRadius: 14, paddingHorizontal: 10, marginBottom: 8, }, inputInline: { flex: 1, paddingVertical: 6, fontSize: 13 }, expenseItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10, }, divider: { height: StyleSheet.hairlineWidth, marginVertical: 6, }, tag: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, marginRight: 6, marginTop: 4, }, tagText: { fontSize: 11 }, chartRow: { marginTop: 6 }, chartBarBg: { height: 10, borderRadius: 999, overflow: 'hidden', }, chartBarFill: { height: 10, borderRadius: 999, }, chatBubble: { padding: 10, borderRadius: 14, marginVertical: 4, maxWidth: '85%', }, chatUser: { alignSelf: 'flex-end' }, chatAi: { alignSelf: 'flex-start' }, badge: { paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, marginRight: 6, marginBottom: 6, }, avatarCircle: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', }, avatarImage: { width: 44, height: 44, borderRadius: 22, }, goalImage: { width: '100%', height: 120, borderRadius: 12, marginTop: 6, }, }); // ---------- helpers ---------- const formatCurrency = (amount, currency = DEFAULT_CURRENCY) => { const n = Number.isFinite(amount) ? amount : 0; const rounded = Math.round(n); return `${currency} ${rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}`; }; const formatDate = (isoString) => { if (!isoString) return ''; const d = new Date(isoString); if (Number.isNaN(d.getTime())) return ''; return d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short', }); }; const getMonthKey = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; const guessCategory = (title) => { if (!title) return 'Другое'; const t = title.toLowerCase(); if (/(super|market|магазин|магнит|кошелек|grocery|food|еда|dodo|kfc|burger)/.test(t)) return 'Еда'; if (/(аптека|pharm|apteka|drug)/.test(t)) return 'Здоровье'; if (/(taxi|такси|yandex go|bolt|uber|bus|metro)/.test(t)) return 'Транспорт'; if (/(аренда|rent|ipotek|ипотека|квартира)/.test(t)) return 'Жильё'; if (/(netflix|spotify|youtube|yandex plus|подписка|subscription)/.test(t)) return 'Подписки'; if (/(steam|psn|xbox|game|игр)/.test(t)) return 'Игры/развлечения'; if (/(cafe|кафе|coffee|кофе|restaurant|ресторан)/.test(t)) return 'Еда'; if (/(ozon|wildberries|wb|aliexpress)/.test(t)) return 'Покупки'; return 'Другое'; }; const sumBy = (arr, fn) => arr.reduce((acc, item, idx) => acc + (fn(item, idx) || 0), 0); const buildMonthlyTotals = (expenses) => { const totals = {}; expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); const key = getMonthKey(d); totals[key] = (totals[key] || 0) + (Number(e.amount) || 0); }); return totals; }; const buildCategoryTotalsForMonth = (expenses, monthKey) => { const m = {}; expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); if (getMonthKey(d) !== monthKey) return; const cat = e.category || 'Другое'; m[cat] = (m[cat] || 0) + (Number(e.amount) || 0); }); return m; }; const forecastNextMonth = (expenses) => { const totalsMap = buildMonthlyTotals(expenses); const keys = Object.keys(totalsMap).sort(); if (keys.length === 0) return 0; const ys = keys.map((k) => totalsMap[k]); if (keys.length === 1) return ys[0]; const n = ys.length; let sumX = 0; let sumY = 0; let sumXY = 0; let sumX2 = 0; for (let i = 0; i < n; i += 1) { const x = i; const y = ys[i]; sumX += x; sumY += y; sumXY += x * y; sumX2 += x * x; } const denom = n * sumX2 - sumX * sumX; const slope = denom === 0 ? 0 : (n * sumXY - sumX * sumY) / denom; const intercept = (sumY - slope * sumX) / n; const forecast = intercept + slope * n; return forecast > 0 ? forecast : ys[ys.length - 1]; }; const detectAnomalies = (expenses) => { if (!expenses.length) return []; const amounts = expenses.map((e) => Number(e.amount) || 0); const avg = sumBy(amounts, (a) => a) / amounts.length; const threshold = Math.max(avg * 2, 0); return expenses.filter((e) => (Number(e.amount) || 0) > threshold && threshold > 0); }; const calculateDailyStreak = (expenses, monthlyIncome) => { if (!expenses.length || !monthlyIncome) return 0; const dailyBudget = monthlyIncome / 30; if (dailyBudget <= 0) return 0; const byDay = {}; expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); const key = d.toISOString().substring(0, 10); byDay[key] = (byDay[key] || 0) + (Number(e.amount) || 0); }); const today = new Date(); let streak = 0; for (let i = 0; i < 365; i += 1) { const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000); const key = d.toISOString().substring(0, 10); const spent = byDay[key] || 0; if (spent <= dailyBudget) { streak += 1; } else { break; } } return streak; }; const countNoSpendDaysThisMonth = (expenses) => { if (!expenses.length) return 0; const now = new Date(); const year = now.getFullYear(); const month = now.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const spentDays = new Set(); expenses.forEach((e) => { if (e.type === 'income') return; const d = new Date(e.date || new Date()); if (d.getFullYear() !== year || d.getMonth() !== month) return; spentDays.add(d.getDate()); }); return daysInMonth - spentDays.size; }; const calculateFinancialHealth = (userData) => { const expenses = userData.expenses || []; const settings = userData.settings || {}; const monthlyIncome = Number(settings.monthlyIncome) || Number(userData.income) || 0; const now = new Date(); const currentMonthKey = getMonthKey(now); const monthTotals = buildMonthlyTotals(expenses); const totalThisMonth = monthTotals[currentMonthKey] || 0; const savings = Math.max(0, monthlyIncome - totalThisMonth); const savingsRate = monthlyIncome > 0 ? savings / monthlyIncome : 0; const unnecessary = sumBy(expenses, (e) => { if (e.type === 'income') return 0; const d = new Date(e.date || new Date()); if (getMonthKey(d) !== currentMonthKey) return 0; const c = (e.category || '').toLowerCase(); if ( c.includes('игр') || c.includes('развлеч') || c.includes('подпис') || c.includes('покуп') || c.includes('кафе') || c.includes('bar') || c.includes('club') ) { return Number(e.amount) || 0; } return 0; }); const unnecessaryShare = monthlyIncome > 0 ? unnecessary / monthlyIncome : 0; let score = 50; score += Math.min(20, savingsRate * 40); score -= Math.min(15, unnecessaryShare * 30); if (userData.goals && userData.goals.length) score += 5; if (expenses.length > 20) score += 5; score = Math.max(0, Math.min(100, Math.round(score))); let label = 'Нужно больше данных'; if (score >= 80) { label = 'Отличное состояние'; } else if (score >= 60) { label = 'Хорошее состояние'; } else if (score >= 40) { label = 'Среднее, есть над чем поработать'; } else { label = 'Слабое, нужен план'; } return { score, label, monthlyIncome, totalThisMonth, savings, unnecessary, }; }; // простое шифрование заметок по PIN (XOR + hex, не для продакшена) const encryptNote = (plain, pin) => { if (!plain) return ''; if (!pin) return plain; const digits = pin.split('').map((d) => parseInt(d, 10) || 0); if (!digits.length) return plain; let out = ''; for (let i = 0; i < plain.length; i += 1) { const code = plain.charCodeAt(i) ^ digits[i % digits.length]; out += code.toString(16).padStart(2, '0'); } return out; }; const decryptNote = (cipher, pin) => { if (!cipher) return ''; if (!pin) return cipher; const digits = pin.split('').map((d) => parseInt(d, 10) || 0); if (!digits.length) return cipher; try { let out = ''; for (let i = 0; i < cipher.length; i += 2) { const hex = cipher.substr(i, 2); const code = parseInt(hex, 16) ^ digits[(i / 2) % digits.length]; out += String.fromCharCode(code); } return out; } catch (e) { return ''; } }; const buildSmartReminders = (userData) => { const reminders = []; const expenses = userData.expenses || []; const settings = userData.settings || {}; const now = new Date(); const nowKey = getMonthKey(now); const last7Total = sumBy(expenses, (e) => { if (e.type === 'income') return 0; const d = new Date(e.date || new Date()); const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24); if (diff >= 0 && diff <= 7) { return Number(e.amount) || 0; } return 0; }); const weeklyBudget = Number(settings.weeklyBudget) || 0; if (weeklyBudget > 0 && last7Total > weeklyBudget) { reminders.push( `За последние 7 дней вы уже вышли за недельный бюджет на ${formatCurrency( last7Total - weeklyBudget, settings.currency || DEFAULT_CURRENCY, )}.`, ); } else if (weeklyBudget > 0 && last7Total > weeklyBudget * 0.8) { reminders.push('Вы приблизились к недельному бюджету — самое время притормозить расходы.'); } (userData.goals || []).forEach((g) => { const target = Number(g.targetAmount) || 0; const saved = Number(g.savedAmount) || 0; if (!target) return; const d = g.deadline ? new Date(g.deadline) : null; const left = target - saved; if (left <= 0 || !d) return; const daysLeft = Math.round((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); if (daysLeft <= 0) return; const perDay = left / daysLeft; if (perDay > 0) { reminders.push( `До цели «${g.title}» осталось ${formatCurrency( left, settings.currency || DEFAULT_CURRENCY, )}. Нужно откладывать примерно ${formatCurrency( perDay, settings.currency || DEFAULT_CURRENCY, )} в день.`, ); } }); const monthTotals = buildMonthlyTotals(expenses); const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); const prevKey = getMonthKey(prevMonth); const totalNow = monthTotals[nowKey] || 0; const totalPrev = monthTotals[prevKey] || 0; if (totalPrev > 0 && totalNow > totalPrev * 1.15) { const diff = totalNow - totalPrev; reminders.push( `В этом месяце ваши траты уже на ${formatCurrency( diff, settings.currency || DEFAULT_CURRENCY, )} больше, чем в прошлом.`, ); } if (!reminders.length) { reminders.push('Вы молодец — критичных напоминаний сейчас нет 🎉'); } return reminders; }; const buildInsightsSummary = (userData) => { const expenses = userData.expenses || []; const settings = userData.settings || {}; const now = new Date(); const currentMonthKey = getMonthKey(now); const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); const prevKey = getMonthKey(prevMonth); const monthTotals = buildMonthlyTotals(expenses); const totalNow = monthTotals[currentMonthKey] || 0; const totalPrev = monthTotals[prevKey] || 0; const catNow = buildCategoryTotalsForMonth(expenses, currentMonthKey); const catPrev = buildCategoryTotalsForMonth(expenses, prevKey); const entriesNow = Object.entries(catNow); const topCat = entriesNow.sort((a, b) => b[1] - a[1])[0]; const currency = settings.currency || DEFAULT_CURRENCY; const anomalies = detectAnomalies(expenses).slice(0, 3); const lines = []; if (totalPrev > 0 && totalNow > 0) { const delta = totalNow - totalPrev; const pct = (delta / totalPrev) * 100; if (Math.abs(pct) >= 5) { if (pct > 0) { lines.push( `Общие расходы выросли на ${pct.toFixed(1)}% по сравнению с прошлым месяцем (${formatCurrency( totalNow, currency, )} против ${formatCurrency(totalPrev, currency)}).`, ); } else { lines.push( `Вы тратите на ${Math.abs(pct).toFixed( 1, )}% меньше, чем в прошлом месяце — отличный прогресс 👏`, ); } } } if (topCat) { lines.push( `Больше всего вы тратите на категорию «${topCat[0]}» — ${formatCurrency( topCat[1], currency, )} в этом месяце.`, ); } Object.keys(catNow).forEach((c) => { const nowVal = catNow[c]; const prevVal = catPrev[c] || 0; if (prevVal <= 0) return; const pct = (nowVal - prevVal) / prevVal; if (pct >= 0.25 && nowVal > 0) { lines.push( `Категория «${c}» выросла на ${Math.round( pct * 100, )}% относительно прошлого месяца — стоит проверить, можно ли там сэкономить.`, ); } }); if (anomalies.length) { const txt = anomalies .map((a) => `"${a.title}" на ${formatCurrency(a.amount, currency)}`) .join(', '); lines.push(`Обнаружены разовые крупные траты: ${txt}. Убедитесь, что они действительно нужны.`); } if (!lines.length) { lines.push('Пока недостаточно данных для глубокого анализа. Начните добавлять траты 🙂'); } return lines; }; const generateAiAnswer = (promptRaw, userData) => { const prompt = promptRaw.toLowerCase(); const health = calculateFinancialHealth(userData); const insights = buildInsightsSummary(userData); const currency = (userData.settings && userData.settings.currency) || DEFAULT_CURRENCY; const expenses = userData.expenses || []; const monthlyTotals = buildMonthlyTotals(expenses); const nowKey = getMonthKey(new Date()); const totalThisMonth = monthlyTotals[nowKey] || 0; if (!expenses.length && !prompt.includes('регистрац')) { return 'У меня пока нет ваших данных. Добавьте несколько трат и доходов — тогда я смогу давать персональные советы.'; } if (prompt.includes('сократить') || prompt.includes('эконом') || prompt.includes('сэкономить')) { const catNow = buildCategoryTotalsForMonth(expenses, nowKey); const entries = Object.entries(catNow).sort((a, b) => b[1] - a[1]); const top3 = entries.slice(0, 3); const tips = top3 .map( ([c, v]) => `• «${c}» — ${formatCurrency( v, currency, )} в месяц. Попробуйте снизить хотя бы на 10–20%.`, ) .join('\n'); return ( `Сейчас ваши траты за месяц: ${formatCurrency( totalThisMonth, currency, )}. Чтобы сократить их, лучше всего начать с самых крупных категорий:\n\n${tips}\n\n` + 'Дополнительно:\n' + '• установите недельный бюджет и фиксируйте все траты;\n' + '• избегайте спонтанных покупок — делайте «паузу 24 часа»;\n' + '• для подписок — оставьте только то, чем пользуетесь каждую неделю.' ); } if (prompt.includes('как распределить') || prompt.includes('зарплат')) { const match = promptRaw.match(/(\d[\d\s]{2,})/); const income = match ? parseInt(match[1].replace(/\s/g, ''), 10) : health.monthlyIncome || 0; if (!income) { return 'Напишите, пожалуйста, размер зарплаты (например: «Как распределить зарплату 300 000?»), или задайте месячный доход в настройках. Тогда я предложу детальный план распределения.'; } const needs = income * 0.5; const wants = income * 0.3; const saving = income * 0.2; return ( `Классическая модель 50/30/20 для зарплаты ${formatCurrency(income, currency)}:\n\n` + `• 50% (${formatCurrency(needs, currency)}) — обязательные расходы (жильё, еда, транспорт, кредиты);\n` + `• 30% (${formatCurrency(wants, currency)}) — «хотелки» и развлечения;\n` + `• 20% (${formatCurrency(saving, currency)}) — сбережения и цели.\n\n` + 'Вы можете адаптировать: если есть кредиты — уменьшите «хотелки» до 10–15% и направьте разницу на досрочное погашение и накопления.' ); } if (prompt.includes('стоит ли покупать') || prompt.includes('покупать')) { return ( 'Задайте себе три вопроса перед покупкой:\n' + '1) Нужна ли вещь прямо сейчас или это импульс?\n' + '2) Сколько часов вашей работы она стоит?\n' + '3) Через месяц вы будете так же рады этой покупке?\n\n' + 'Если сумма покупки >10% от месячного дохода — хорошо бы выдержать «паузу 24 часа». ' + 'Также посмотрите, не мешает ли эта покупка вашим финансовым целям (накоплениям и т.п.).' ); } if (prompt.includes('что если') || prompt.includes('если буду')) { return ( 'Функция «Что если?» уже доступна на вкладке «Цели» — там можно смоделировать, что будет, если сократить траты на кафе или начать откладывать больше. ' + 'Если хотите, сформулируйте сценарий конкретно (например: «Что будет, если каждый месяц откладывать на 20 000 больше?»), и я прикину эффект в чате.' ); } const baseInsights = insights.slice(0, 2).join('\n\n'); return ( `Краткий обзор вашего финансового состояния:\n` + `• Индекс финансового здоровья: ${health.score}/100 (${health.label}).\n` + `• Траты в этом месяце: ${formatCurrency(totalThisMonth, currency)}.\n` + `• Потенциальные сбережения: ${formatCurrency(health.savings, currency)}.\n\n` + `${baseInsights}\n\n` + 'Задавайте мне конкретные вопросы: например, «Как сократить расходы на 20%?» или ' + '«Помоги перераспределить бюджет с учётом цели на 800 000».' ); }; // helper для дефолтных данных пользователя const createDefaultUserData = (themeMode, profile = {}) => ({ income: 0, expenses: [], goals: [], subscriptions: [], settings: { monthlyIncome: 0, weeklyBudget: 0, currency: DEFAULT_CURRENCY, antiImpulseEnabled: false, antiImpulseThreshold: 0, themeMode: themeMode || 'dark', }, profile, secureNote: '', }); // helper для выбора картинки из галереи const pickImageFromLibrary = async () => { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { Alert.alert('Нет доступа', 'Разрешите доступ к фото в настройках устройства.'); return null; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8, }); if (result.canceled) return null; const asset = result.assets && result.assets[0]; if (!asset) return null; return asset.uri; }; // ---------- небольшие компоненты ---------- const CategoryBarChart = ({ data, theme }) => { if (!data || !data.length) return null; const max = Math.max(...data.map((d) => d.value || 0)); if (!max) return null; return ( <View style={base.chartRow}> {data.map((item) => { const pct = (item.value / max) * 100; return ( <View key={item.label} style={{ marginBottom: 6 }}> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted }]}>{item.label}</Text> <Text style={[base.tinyText, { color: theme.text }]}> {formatCurrency(item.value)} </Text> </View> <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 3 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accent, width: `${pct}%`, }, ]} /> </View> </View> ); })} </View> ); }; const SimpleMonthlyChart = ({ expenses, theme }) => { const totalsMap = buildMonthlyTotals(expenses || []); const keys = Object.keys(totalsMap).sort(); if (!keys.length) return null; const items = keys.slice(-6).map((k) => ({ key: k, value: totalsMap[k] })); const max = Math.max(...items.map((i) => i.value)); if (!max) return null; return ( <View style={{ marginTop: 4 }}> {items.map((item) => { const [year, month] = item.key.split('-'); const label = `${month}.${year.slice(-2)}`; const pct = (item.value / max) * 100; return ( <View key={item.key} style={{ marginBottom: 4 }}> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted }]}>{label}</Text> <Text style={[base.tinyText, { color: theme.text }]}> {formatCurrency(item.value)} </Text> </View> <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 3 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accentSoft, width: `${pct}%`, }, ]} /> </View> </View> ); })} </View> ); }; const SpendingGauge = ({ monthlyIncome, spent, theme, currency }) => { if (!monthlyIncome) return null; const ratio = monthlyIncome > 0 ? spent / monthlyIncome : 0; const pct = Math.max(0, Math.min(1, ratio)); const labelPct = (pct * 100).toFixed(0); return ( <View style={{ marginTop: 10 }}> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Расходы от дохода</Text> <Text style={[base.tinyText, { color: theme.text }]}> {labelPct}% ({formatCurrency(spent, currency)}) </Text> </View> <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 4, height: 12 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: ratio > 1 ? theme.danger : theme.accent, width: `${pct * 100}%`, }, ]} /> </View> {ratio > 1 && ( <Text style={[base.tinyText, { color: theme.danger, marginTop: 4 }]}> Вы вышли за рамки месячного дохода — стоит пересмотреть план. </Text> )} </View> ); }; // ---------- экраны ---------- const AuthScreen = ({ users, onLogin, onRegister, theme }) => { const [mode, setMode] = useState('login'); // 'login' | 'register' const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [pin, setPin] = useState(''); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [phone, setPhone] = useState(''); const [birthDate, setBirthDate] = useState(''); const [avatarUri, setAvatarUri] = useState(''); const [error, setError] = useState(''); const toggleMode = () => { setError(''); setMode((m) => (m === 'login' ? 'register' : 'login')); }; const handleLoginPress = () => { setError(''); const normalizedEmail = email.trim().toLowerCase(); if (!normalizedEmail || !password) { setError('Заполните email и пароль.'); return; } const user = users.find((u) => u.email === normalizedEmail); if (!user || user.password !== password) { setError('Неверный email или пароль.'); return; } onLogin(user); }; const handleRegisterPress = () => { setError(''); const normalizedEmail = email.trim().toLowerCase(); if (!normalizedEmail || !password) { setError('Заполните email и пароль.'); return; } if (!normalizedEmail.includes('@')) { setError('Некорректный email.'); return; } if (password.length < 4) { setError('Пароль должен быть не короче 4 символов.'); return; } if (!firstName.trim()) { setError('Укажите имя.'); return; } if (!phone.trim()) { setError('Укажите номер телефона.'); return; } if (users.some((u) => u.email === normalizedEmail)) { setError('Пользователь с таким email уже существует.'); return; } const newUser = { id: `u_${Date.now()}`, email: normalizedEmail, password, pin: pin || '', createdAt: new Date().toISOString(), }; const profile = { firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), birthDate: birthDate.trim(), avatarUri: avatarUri || '', }; onRegister(newUser, profile); }; const handlePickAvatar = async () => { const uri = await pickImageFromLibrary(); if (uri) { setAvatarUri(uri); } }; return ( <SafeAreaView style={[ base.container, { backgroundColor: theme.background }, ]} > <StatusBar barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'} /> <KeyboardAvoidingView style={base.flex1} behavior={Platform.OS === 'ios' ? 'padding' : undefined} > <View style={[base.authContainer]}> <View style={{ marginBottom: 32 }}> <Text style={[base.title, { color: theme.text }]}> Финансовый ИИ-помощник </Text> <Text style={[base.subtitle, { color: theme.textMuted }]}> Управляй доходами, расходами, целями и получай подсказки от ИИ. </Text> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[ base.cardTitle, { color: theme.text }, ]} > {mode === 'login' ? 'Вход' : 'Регистрация'} </Text> <View style={[ base.pill, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.pillText, { color: theme.accent }, ]} > ⚡ ИИ-помощник </Text> </View> </View> {error ? ( <Text style={[base.errorText, { color: theme.danger }]}>{error}</Text> ) : null} {mode === 'register' && ( <> <View style={base.row}> <TouchableOpacity onPress={handlePickAvatar}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> + </Text> </View> )} </TouchableOpacity> <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото профиля (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте аватар, чтобы приложение стало более личным. </Text> </View> </View> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} keyboardType="phone-pad" value={phone} onChangeText={setPhone} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </> )} <TextInput placeholder="Email" placeholderTextColor={theme.textMuted} autoCapitalize="none" keyboardType="email-address" value={email} onChangeText={setEmail} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Пароль" placeholderTextColor={theme.textMuted} secureTextEntry value={password} onChangeText={setPassword} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> {mode === 'register' && ( <TextInput placeholder="PIN (необязательно, для шифрования заметок)" placeholderTextColor={theme.textMuted} value={pin} onChangeText={setPin} keyboardType="number-pad" maxLength={6} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> )} <TouchableOpacity onPress={mode === 'login' ? handleLoginPress : handleRegisterPress} style={[ base.button, { backgroundColor: theme.accent, marginTop: 8 }, ]} > <Text style={[ base.buttonText, { color: '#FFFFFF' }, ]} > {mode === 'login' ? 'Войти' : 'Зарегистрироваться'} </Text> </TouchableOpacity> <TouchableOpacity onPress={toggleMode}> <Text style={[ base.link, { color: theme.textMuted }, ]} > {mode === 'login' ? 'Нет аккаунта? Создать' : 'Уже есть аккаунт? Войти'} </Text> </TouchableOpacity> </View> <View style={{ marginTop: 24 }}> <Text style={[ base.smallText, { color: theme.textMuted }, ]} > Данные хранятся только локально на устройстве (AsyncStorage). Для реального приложения стоит добавить серверный бэкап и более серьёзную криптографию. </Text> </View> </View> </KeyboardAvoidingView> </SafeAreaView> ); }; const HomeScreen = ({ userData, theme }) => { const health = calculateFinancialHealth(userData); const insights = buildInsightsSummary(userData); const reminders = buildSmartReminders(userData); const expenses = userData.expenses || []; const settings = userData.settings || {}; const currency = settings.currency || DEFAULT_CURRENCY; const now = new Date(); const currentMonthKey = getMonthKey(now); const catNowMap = buildCategoryTotalsForMonth(expenses, currentMonthKey); const categoryData = Object.entries(catNowMap) .map(([label, value]) => ({ label, value })) .sort((a, b) => b.value - a.value); const forecast = forecastNextMonth(expenses); const streak = calculateDailyStreak( expenses, Number(settings.monthlyIncome) || Number(userData.income) || 0, ); const noSpendDays = countNoSpendDaysThisMonth(expenses); const profile = userData.profile || {}; const firstName = profile.firstName || ''; const badges = []; if (health.score >= 80) badges.push('Экономист месяца'); if (streak >= 7) badges.push('7 дней в плюсе'); if (noSpendDays >= 5) badges.push('Дни без трат'); if (expenses.length >= 50) badges.push('Финансовый самурай'); return ( <ScrollView style={[ base.content, { backgroundColor: theme.background }, ]} showsVerticalScrollIndicator={false} > <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.rowBetween}> <View style={{ flex: 1 }}> <Text style={[base.cardSubtitle, { color: theme.textMuted }]}> Индекс финансового здоровья </Text> <Text style={[ { fontSize: 32, fontWeight: '700', marginTop: 4 }, { color: theme.text }, ]} > {health.score} <Text style={{ fontSize: 16 }}>/100</Text> </Text> {firstName ? ( <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 4 }, ]} > {firstName}, это ваш персональный индекс. </Text> ) : null} </View> <View style={[ { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 16, }, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.tinyText, { color: theme.textMuted }]}>Статус</Text> <Text style={[ base.smallText, { color: theme.text, fontWeight: '600', marginTop: 2 }, ]} > {health.label} </Text> </View> </View> <View style={{ marginTop: 10 }}> <View style={base.rowBetween}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Доход / мес.</Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {health.monthlyIncome ? formatCurrency(health.monthlyIncome, currency) : 'Не задан'} </Text> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Траты в этом месяце </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(health.totalThisMonth, currency)} </Text> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Сбережения</Text> <Text style={[ base.smallText, { color: health.savings >= 0 ? theme.success : theme.danger, marginTop: 2, }, ]} > {formatCurrency(health.savings, currency)} </Text> </View> <SpendingGauge monthlyIncome={health.monthlyIncome} spent={health.totalThisMonth} theme={theme} currency={currency} /> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Привычки</Text> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Серия дней в рамках бюджета: {streak} </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Дней без трат в этом месяце: {noSpendDays} </Text> </View> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Бейджи</Text> <View style={[base.row, { flexWrap: 'wrap', marginTop: 4 }]}> {badges.length ? ( badges.map((b) => ( <View key={b} style={[ base.badge, { backgroundColor: theme.accentSoft }, ]} > <Text style={[ base.tinyText, { color: theme.accent }, ]} > ✨ {b} </Text> </View> )) ) : ( <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Собирайте награды, держась в рамках бюджета. </Text> )} </View> </View> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>AI-инсайты</Text> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>🧩 Анализ</Text> </View> </View> {insights.slice(0, 3).map((line, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{line}</Text> </View> ))} </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Категории</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Текущий месяц </Text> </View> <CategoryBarChart data={categoryData} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Динамика</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Прогноз: {formatCurrency(forecast, currency)} </Text> </View> <SimpleMonthlyChart expenses={expenses} theme={theme} /> </View> <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Умные напоминания</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔔</Text> </View> {reminders.map((r, idx) => ( <View key={idx} style={{ marginTop: idx === 0 ? 0 : 6 }}> <Text style={[base.smallText, { color: theme.text }]}>{r}</Text> </View> ))} </View> </ScrollView> ); }; const ExpensesScreen = ({ userData, onUpdateUserData, theme }) => { const [title, setTitle] = useState(''); const [amount, setAmount] = useState(''); const [category, setCategory] = useState(''); const [isIncome, setIsIncome] = useState(false); const expenses = [...(userData.expenses || [])].sort( (a, b) => new Date(b.date || 0).getTime() - new Date(a.date || 0).getTime(), ); const settings = userData.settings || {}; const currency = settings.currency || DEFAULT_CURRENCY; const antiImpulseEnabled = settings.antiImpulseEnabled; const antiImpulseThreshold = Number(settings.antiImpulseThreshold) || 0; const addEntry = (n, guessed, customTitle, extraMeta) => { const entry = { id: `exp_${Date.now()}`, title: (customTitle || title).trim(), amount: n, category: guessed, type: isIncome ? 'income' : 'expense', date: new Date().toISOString(), ...(extraMeta ? { meta: extraMeta } : {}), }; onUpdateUserData((prev) => ({ ...prev, expenses: [...(prev.expenses || []), entry], })); setTitle(''); setAmount(''); setCategory(''); setIsIncome(false); }; const handleAdd = () => { const n = parseFloat(String(amount).replace(',', '.')); if (!title || !Number.isFinite(n) || n <= 0) { Alert.alert('Ошибка', 'Введите название и сумму.'); return; } const guessed = category || guessCategory(title); if (!isIncome && antiImpulseEnabled && antiImpulseThreshold > 0 && n >= antiImpulseThreshold) { Alert.alert( 'Анти-импульсная защита', `Покупка на ${formatCurrency( n, currency, )} превышает ваш лимит в ${formatCurrency(antiImpulseThreshold, currency)}.\n\n` + 'Вы уверены, что хотите продолжить?', [ { text: 'Отменить', style: 'cancel' }, { text: 'Купить', style: 'destructive', onPress: () => addEntry(n, guessed), }, ], ); return; } addEntry(n, guessed); }; const totalExpenses = sumBy(expenses, (e) => e.type === 'income' ? 0 : Number(e.amount) || 0, ); const totalIncome = sumBy(expenses, (e) => e.type === 'income' ? Number(e.amount) || 0 : 0, ); return ( <View style={[ base.content, { backgroundColor: theme.background }, ]} > <ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > {/* Ручное добавление записи */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Новая запись</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}> Доход </Text> <Switch value={isIncome} onValueChange={setIsIncome} thumbColor={isIncome ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Магазин / описание" placeholderTextColor={theme.textMuted} value={title} onChangeText={(text) => { setTitle(text); if (!category) { setCategory(guessCategory(text)); } }} style={[base.inputInline, { color: theme.text }]} /> </View> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, }, ]} > <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} keyboardType="numeric" value={amount} onChangeText={setAmount} style={[base.inputInline, { color: theme.text }]} /> <Text style={[base.smallText, { color: theme.textMuted }]}>{currency}</Text> </View> <View style={{ marginTop: 8 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}>Категория</Text> <ScrollView horizontal showsHorizontalScrollIndicator={false}> {DEFAULT_CATEGORIES.map((c) => ( <TouchableOpacity key={c} onPress={() => setCategory(c)} style={[ base.tag, { backgroundColor: category === c ? theme.accentSoft : theme.cardAlt, borderWidth: 1, borderColor: category === c ? theme.accent : theme.border, }, ]} > <Text style={[ base.tagText, { color: category === c ? theme.accent : theme.textMuted, }, ]} > {c} </Text> </TouchableOpacity> ))} </ScrollView> </View> <TouchableOpacity onPress={handleAdd} style={[ base.button, { backgroundColor: theme.accent, marginTop: 12 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить </Text> </TouchableOpacity> </View> {/* Итоги */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.rowBetween}> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего расходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalExpenses, currency)} </Text> </View> <View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего доходов </Text> <Text style={[base.smallText, { color: theme.text, marginTop: 2 }]}> {formatCurrency(totalIncome, currency)} </Text> </View> </View> </View> {/* История */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>История</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> {expenses.length} записей </Text> </View> {expenses.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Добавьте первую трату или доход. </Text> ) : ( <FlatList data={expenses} keyExtractor={(item) => item.id} scrollEnabled={false} renderItem={({ item }) => { const isImpulseHighlight = item.type !== 'income' && antiImpulseEnabled && antiImpulseThreshold > 0 && Number(item.amount) >= antiImpulseThreshold; return ( <View> <View style={base.expenseItem}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> {item.title} </Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted }]}> {formatDate(item.date)} </Text> <View style={[ base.tag, { backgroundColor: theme.cardAlt, marginTop: 0, }, ]} > <Text style={[ base.tagText, { color: theme.textMuted }, ]} > {item.category || 'Другое'} </Text> </View> </View> </View> <Text style={[ base.smallText, { color: item.type === 'income' ? theme.success : isImpulseHighlight ? theme.danger : theme.text, }, ]} > {item.type === 'income' ? '+' : '-'} {formatCurrency(item.amount, currency)} </Text> </View> <View style={[ base.divider, { backgroundColor: theme.border }, ]} /> </View> ); }} /> )} </View> </ScrollView> </View> ); }; const GoalsScreen = ({ userData, onUpdateUserData, theme }) => { const [title, setTitle] = useState(''); const [target, setTarget] = useState(''); const [months, setMonths] = useState(''); const [whatIfCafe, setWhatIfCafe] = useState('30'); const [whatIfSave, setWhatIfSave] = useState('10000'); const [goalImage, setGoalImage] = useState(null); const [editGoalId, setEditGoalId] = useState(null); const [editTitle, setEditTitle] = useState(''); const [editTarget, setEditTarget] = useState(''); const [editDeadlineMonths, setEditDeadlineMonths] = useState(''); const goals = userData.goals || []; const settings = userData.settings || {}; const currency = settings.currency || DEFAULT_CURRENCY; const handleAddGoal = () => { const t = parseFloat(String(target).replace(',', '.')); const m = parseInt(months, 10); if (!title || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) { Alert.alert('Ошибка', 'Укажите цель, сумму и срок в месяцах.'); return; } const deadline = new Date(); deadline.setMonth(deadline.getMonth() + m); const newGoal = { id: `goal_${Date.now()}`, title: title.trim(), targetAmount: t, savedAmount: 0, deadline: deadline.toISOString(), imageUri: goalImage || null, }; onUpdateUserData((prev) => ({ ...prev, goals: [...(prev.goals || []), newGoal], })); setTitle(''); setTarget(''); setMonths(''); setGoalImage(null); }; const handleDeposit = (goalId, amount) => { const n = amount || 0; onUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).map((g) => g.id === goalId ? { ...g, savedAmount: (Number(g.savedAmount) || 0) + n } : g, ), expenses: [ ...(prev.expenses || []), { id: `save_${Date.now()}`, title: 'Перевод в копилку', amount: n, category: 'Сбережения', type: 'expense', date: new Date().toISOString(), }, ], })); }; const handlePickGoalImage = async () => { const uri = await pickImageFromLibrary(); if (uri) setGoalImage(uri); }; const handleEditGoalPress = (goal) => { const now = new Date(); const deadline = goal.deadline ? new Date(goal.deadline) : null; const monthsLeft = deadline && !Number.isNaN(deadline.getTime()) ? Math.max( 1, Math.round( (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30), ), ) : ''; setEditGoalId(goal.id); setEditTitle(goal.title); setEditTarget(String(goal.targetAmount || '')); setEditDeadlineMonths(monthsLeft ? String(monthsLeft) : ''); }; const handleSaveEditedGoal = () => { if (!editGoalId) return; const t = parseFloat(String(editTarget).replace(',', '.')); const m = parseInt(editDeadlineMonths, 10); if (!editTitle || !Number.isFinite(t) || t <= 0 || !Number.isFinite(m) || m <= 0) { Alert.alert('Ошибка', 'Укажите корректные данные для цели.'); return; } const newDeadline = new Date(); newDeadline.setMonth(newDeadline.getMonth() + m); onUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).map((g) => g.id === editGoalId ? { ...g, title: editTitle.trim(), targetAmount: t, deadline: newDeadline.toISOString(), } : g, ), })); setEditGoalId(null); setEditTitle(''); setEditTarget(''); setEditDeadlineMonths(''); }; const handleCancelEdit = () => { setEditGoalId(null); setEditTitle(''); setEditTarget(''); setEditDeadlineMonths(''); }; const handleDeleteGoal = (goalId) => { Alert.alert( 'Удалить цель', 'Вы уверены, что хотите удалить эту цель? Прогресс по ней будет потерян.', [ { text: 'Отмена', style: 'cancel' }, { text: 'Удалить', style: 'destructive', onPress: () => { onUpdateUserData((prev) => ({ ...prev, goals: (prev.goals || []).filter((g) => g.id !== goalId), })); }, }, ], ); }; const now = new Date(); const totalSavedAll = sumBy(goals, (g) => Number(g.savedAmount) || 0); const totalTargetAll = sumBy(goals, (g) => Number(g.targetAmount) || 0); const cafeReduce = parseFloat(String(whatIfCafe).replace(',', '.')) || 0; const extraWeekly = parseFloat(String(whatIfSave).replace(',', '.')) || 0; const cafeCurrent = sumBy(userData.expenses || [], (e) => { if (e.type === 'income') return 0; const c = (e.category || '').toLowerCase(); if (c.includes('еда') || c.includes('кафе')) { const d = new Date(e.date || new Date()); const key = getMonthKey(d); if (key === getMonthKey(now)) { return Number(e.amount) || 0; } } return 0; }); const cafePotential = cafeCurrent * (cafeReduce / 100); const whatIfMonthlyExtra = cafePotential + extraWeekly * 4; return ( <ScrollView style={[ base.content, { backgroundColor: theme.background }, ]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" > {/* Блок создания цели */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Цели и копилки</Text> <View style={[base.pill, { backgroundColor: theme.accentSoft }]}> <Text style={[base.pillText, { color: theme.accent }]}>💎 Планы</Text> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Всего накоплено: {formatCurrency(totalSavedAll, currency)} из{' '} {formatCurrency(totalTargetAll || 0, currency)} </Text> <View style={{ marginTop: 12 }}> <TouchableOpacity onPress={handlePickGoalImage} style={[ base.row, { marginBottom: 8, }, ]} > {goalImage ? ( <Image source={{ uri: goalImage }} style={[base.goalImage, { width: 80, height: 80 }]} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}>📷</Text> </View> )} <View style={{ marginLeft: 10, flex: 1 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Фото цели (необязательно) </Text> <Text style={[base.smallText, { color: theme.text }]}> Добавьте визуал — так легче держаться плана. </Text> </View> </TouchableOpacity> <TextInput placeholder="Название цели (например, «Ноутбук»)" placeholderTextColor={theme.textMuted} value={title} onChangeText={setTitle} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={target} onChangeText={setTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={months} onChangeText={setMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <TouchableOpacity onPress={handleAddGoal} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Добавить цель </Text> </TouchableOpacity> </View> </View> {/* Список целей с редактированием и удалением */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Мои цели</Text> </View> {goals.length === 0 ? ( <Text style={[base.smallText, { color: theme.textMuted }]}> Создайте первую цель, чтобы следить за прогрессом. </Text> ) : ( goals.map((g) => { const targetAmount = Number(g.targetAmount) || 0; const savedAmount = Number(g.savedAmount) || 0; const pct = targetAmount ? Math.min(100, (savedAmount / targetAmount) * 100) : 0; const deadline = g.deadline ? new Date(g.deadline) : null; const daysLeft = deadline && !Number.isNaN(deadline.getTime()) ? Math.round( (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), ) : null; const left = Math.max(0, targetAmount - savedAmount); const perWeek = daysLeft && daysLeft > 0 ? (left / daysLeft) * 7 : left / 4 || 0; const isEditing = editGoalId === g.id; return ( <View key={g.id} style={{ marginBottom: 12 }}> <View style={base.rowBetween}> <Text style={[base.smallText, { color: theme.text, maxWidth: '70%' }]}> {g.title} </Text> <View style={base.row}> <TouchableOpacity onPress={() => handleEditGoalPress(g)}> <Text style={[base.tinyText, { color: theme.accent, marginRight: 12 }]}> Изм. </Text> </TouchableOpacity> <TouchableOpacity onPress={() => handleDeleteGoal(g.id)}> <Text style={[base.tinyText, { color: theme.danger }]}> Удалить </Text> </TouchableOpacity> </View> </View> {g.imageUri ? ( <Image source={{ uri: g.imageUri }} style={base.goalImage} /> ) : null} <View style={[ base.chartBarBg, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <View style={[ base.chartBarFill, { backgroundColor: theme.accent, width: `${pct}%`, }, ]} /> </View> <View style={base.rowBetween}> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> {formatCurrency(savedAmount, currency)} из{' '} {formatCurrency(targetAmount, currency)} </Text> {daysLeft !== null && ( <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 4 }, ]} > Осталось ~{daysLeft} дн. </Text> )} </View> {left > 0 && ( <View style={base.rowBetween}> <Text style={[ base.tinyText, { color: theme.textMuted, marginTop: 2 }, ]} > Чтобы успеть: ~{formatCurrency(perWeek, currency)} в неделю </Text> <TouchableOpacity onPress={() => handleDeposit(g.id, 10000)} style={[ base.tag, { backgroundColor: theme.accentSoft, marginTop: 2, }, ]} > <Text style={[ base.tagText, { color: theme.accent }, ]} > +10 000 в копилку </Text> </TouchableOpacity> </View> )} {isEditing && ( <View style={{ marginTop: 8, padding: 10, borderRadius: 12, backgroundColor: theme.cardAlt, }} > <Text style={[base.tinyText, { color: theme.textMuted, marginBottom: 4 }]}> Редактирование цели </Text> <TextInput placeholder="Название цели" placeholderTextColor={theme.textMuted} value={editTitle} onChangeText={setEditTitle} style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <TextInput placeholder="Сумма" placeholderTextColor={theme.textMuted} value={editTarget} onChangeText={setEditTarget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <TextInput placeholder="Срок (мес.)" placeholderTextColor={theme.textMuted} value={editDeadlineMonths} onChangeText={setEditDeadlineMonths} keyboardType="number-pad" style={[ base.input, { backgroundColor: theme.card, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={base.rowBetween}> <TouchableOpacity onPress={handleSaveEditedGoal} style={[ base.button, { flex: 1, backgroundColor: theme.accent, marginTop: 4, marginRight: 4, }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить </Text> </TouchableOpacity> <TouchableOpacity onPress={handleCancelEdit} style={[ base.button, { flex: 1, backgroundColor: theme.card, marginTop: 4, marginLeft: 4, borderWidth: 1, borderColor: theme.border, }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Отмена </Text> </TouchableOpacity> </View> </View> )} <View style={[ base.divider, { backgroundColor: theme.border, marginTop: 8 }, ]} /> </View> ); }) )} </View> {/* Блок «Что если?» */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>«Что если?»</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧪 Симулятор</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Смоделируйте изменения привычек и посмотрите влияние на накопления. </Text> <View style={{ marginTop: 8 }}> <View style={base.row}> <View style={{ flex: 1, marginRight: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> На сколько % сократить кафе/еду? </Text> <TextInput placeholder="30" placeholderTextColor={theme.textMuted} value={whatIfCafe} onChangeText={setWhatIfCafe} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> <View style={{ flex: 1, marginLeft: 6 }}> <Text style={[base.tinyText, { color: theme.textMuted }]}> Доп. отложить в неделю </Text> <TextInput placeholder="10 000" placeholderTextColor={theme.textMuted} value={whatIfSave} onChangeText={setWhatIfSave} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> <View style={{ marginTop: 6 }}> <Text style={[base.smallText, { color: theme.text }]}> При таком сценарии вы сможете дополнительно накопить около{' '} {formatCurrency(whatIfMonthlyExtra, currency)} в месяц. </Text> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> За год это будет примерно{' '} {formatCurrency(whatIfMonthlyExtra * 12, currency)} — почти целая отдельная цель 💡 </Text> </View> </View> </View> </ScrollView> ); }; const AiChatScreen = ({ userData, theme }) => { const [messages, setMessages] = useState([ { id: 'm_hello', from: 'ai', text: 'Привет! Я твой финансовый ИИ-ассистент. Задавай вопросы: «Как сократить расходы на 20%?», «Как распределить зарплату?», «Что будет, если начать откладывать больше?»', }, ]); const [input, setInput] = useState(''); const [voiceOn, setVoiceOn] = useState(false); const sendText = (text) => { const trimmed = text.trim(); if (!trimmed) return; const userMsg = { id: `m_${Date.now()}_u`, from: 'user', text: trimmed, }; const aiText = generateAiAnswer(trimmed, userData); const aiMsg = { id: `m_${Date.now()}_ai`, from: 'ai', text: aiText, }; setMessages((prev) => [...prev, userMsg, aiMsg]); setInput(''); if (voiceOn) { Speech.stop(); Speech.speak(aiText, { language: 'ru-RU' }); } }; const handleSend = () => { sendText(input); }; const quickPrompts = [ 'Как сократить расходы на 20%?', 'Как распределить зарплату 300 000?', 'Стоит ли покупать новый телефон?', 'Сделай обзор моих трат.', ]; return ( <KeyboardAvoidingView style={[ base.content, { backgroundColor: theme.background }, ]} behavior={Platform.OS === 'ios' ? 'padding' : undefined} > <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>ИИ-консультант</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 4 }]}> Голос </Text> <Switch value={voiceOn} onValueChange={setVoiceOn} thumbColor={voiceOn ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Ответы основаны на ваших реальных данных: расходах, доходах, целях. </Text> <View style={{ marginTop: 8, flexDirection: 'row', flexWrap: 'wrap', }} > {quickPrompts.map((p) => ( <TouchableOpacity key={p} onPress={() => sendText(p)} style={[ base.badge, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.tinyText, { color: theme.text }]}>{p}</Text> </TouchableOpacity> ))} </View> </View> <View style={[ base.card, { flex: 1, backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 8 }} showsVerticalScrollIndicator={false} > {messages.map((m) => { const isUser = m.from === 'user'; return ( <View key={m.id} style={[ base.chatBubble, isUser ? base.chatUser : base.chatAi, { backgroundColor: isUser ? theme.accent : theme.cardAlt, }, ]} > <Text style={[ base.tinyText, { color: isUser ? '#FFFFFF' : theme.textMuted }, ]} > {isUser ? 'Вы' : 'ИИ'} </Text> <Text style={[ base.smallText, { color: isUser ? '#FFFFFF' : theme.text, marginTop: 2, }, ]} > {m.text} </Text> </View> ); })} </ScrollView> <View style={[ base.inputRow, { backgroundColor: theme.cardAlt, borderWidth: 1, borderColor: theme.border, marginTop: 4, }, ]} > <TextInput placeholder="Задайте вопрос по финансам..." placeholderTextColor={theme.textMuted} value={input} onChangeText={setInput} style={[base.inputInline, { color: theme.text }]} multiline /> <TouchableOpacity onPress={handleSend}> <Text style={[base.smallText, { color: theme.accent }]}>Отпр.</Text> </TouchableOpacity> </View> </View> </KeyboardAvoidingView> ); }; const ProfileScreen = ({ currentUser, userData, onUpdateUserData, theme, onLogout, onToggleTheme, }) => { const profile = userData.profile || {}; const [firstName, setFirstName] = useState(profile.firstName || ''); const [lastName, setLastName] = useState(profile.lastName || ''); const [phone, setPhone] = useState(profile.phone || ''); const [birthDate, setBirthDate] = useState(profile.birthDate || ''); const [avatarUri, setAvatarUri] = useState(profile.avatarUri || ''); const [monthlyIncome, setMonthlyIncome] = useState( String((userData.settings && userData.settings.monthlyIncome) || ''), ); const [weeklyBudget, setWeeklyBudget] = useState( String((userData.settings && userData.settings.weeklyBudget) || ''), ); const [currency, setCurrency] = useState( (userData.settings && userData.settings.currency) || DEFAULT_CURRENCY, ); const [antiImpulseEnabled, setAntiImpulseEnabled] = useState( (userData.settings && userData.settings.antiImpulseEnabled) || false, ); const [antiImpulseThreshold, setAntiImpulseThreshold] = useState( String((userData.settings && userData.settings.antiImpulseThreshold) || ''), ); const [pinInput, setPinInput] = useState(''); const [noteDraft, setNoteDraft] = useState(''); const [noteUnlocked, setNoteUnlocked] = useState(false); useEffect(() => { const savedEncrypted = userData.secureNote || ''; if (savedEncrypted && currentUser.pin) { const dec = decryptNote(savedEncrypted, currentUser.pin); setNoteDraft(dec); } else if (savedEncrypted && !currentUser.pin) { setNoteDraft(savedEncrypted); } else { setNoteDraft(''); } }, [userData.secureNote, currentUser.pin]); useEffect(() => { const p = userData.profile || {}; setFirstName(p.firstName || ''); setLastName(p.lastName || ''); setPhone(p.phone || ''); setBirthDate(p.birthDate || ''); setAvatarUri(p.avatarUri || ''); }, [userData.profile]); const handleSaveSettings = () => { const m = parseFloat(String(monthlyIncome).replace(',', '.')) || 0; const w = parseFloat(String(weeklyBudget).replace(',', '.')) || 0; const anti = parseFloat(String(antiImpulseThreshold).replace(',', '.')) || 0; onUpdateUserData((prev) => ({ ...prev, income: m, settings: { ...(prev.settings || {}), monthlyIncome: m, weeklyBudget: w, currency: currency || DEFAULT_CURRENCY, antiImpulseEnabled, antiImpulseThreshold: anti, }, profile: { ...(prev.profile || {}), firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), birthDate: birthDate.trim(), avatarUri: avatarUri || '', }, })); Alert.alert('Сохранено', 'Профиль и бюджет обновлены.'); }; const handleUnlockNote = () => { if (!currentUser.pin) { setNoteUnlocked(true); return; } if (pinInput === currentUser.pin) { const dec = decryptNote(userData.secureNote || '', currentUser.pin); setNoteDraft(dec); setNoteUnlocked(true); } else { Alert.alert('Ошибка', 'Неверный PIN.'); } }; const handleSaveNote = () => { let encrypted = noteDraft; if (currentUser.pin) { encrypted = encryptNote(noteDraft, currentUser.pin); } onUpdateUserData((prev) => ({ ...prev, secureNote: encrypted, })); Alert.alert('Сохранено', 'Заметка зашифрована и сохранена.'); }; const totalSubs = (userData.subscriptions || []).length; const handlePickAvatar = async () => { const uri = await pickImageFromLibrary(); if (uri) setAvatarUri(uri); }; const displayName = firstName || lastName ? `${firstName} ${lastName}`.trim() : currentUser.email; return ( <ScrollView style={[ base.content, { backgroundColor: theme.background }, ]} showsVerticalScrollIndicator={false} > {/* Профиль и быстрый статус */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <View style={base.row}> <TouchableOpacity onPress={handlePickAvatar}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> {firstName ? firstName.charAt(0).toUpperCase() : currentUser.email.charAt(0).toUpperCase()} </Text> </View> )} </TouchableOpacity> <View style={{ marginLeft: 10 }}> <Text style={[base.cardTitle, { color: theme.text }]}> {displayName || 'Пользователь'} </Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> {currentUser.email} </Text> </View> </View> <TouchableOpacity onPress={onLogout}> <Text style={[base.smallText, { color: theme.danger }]}>Выйти</Text> </TouchableOpacity> </View> <Text style={[base.tinyText, { color: theme.textMuted, marginTop: 4 }]}> Подписок учтено: {totalSubs} • PIN установлен:{' '} {currentUser.pin ? 'да' : 'нет'} </Text> <View style={{ marginTop: 12 }}> <TextInput placeholder="Имя" placeholderTextColor={theme.textMuted} value={firstName} onChangeText={setFirstName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Фамилия" placeholderTextColor={theme.textMuted} value={lastName} onChangeText={setLastName} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Телефон" placeholderTextColor={theme.textMuted} value={phone} onChangeText={setPhone} keyboardType="phone-pad" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Дата рождения (например, 01.01.1995)" placeholderTextColor={theme.textMuted} value={birthDate} onChangeText={setBirthDate} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> </View> </View> {/* Бюджет и валюта */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Бюджет</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Установите доход, недельный бюджет и валюту — это нужно для индекса «финансовое здоровье» и рекомендаций. </Text> <TextInput placeholder="Доход в месяц" placeholderTextColor={theme.textMuted} value={monthlyIncome} onChangeText={setMonthlyIncome} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TextInput placeholder="Недельный бюджет" placeholderTextColor={theme.textMuted} value={weeklyBudget} onChangeText={setWeeklyBudget} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TextInput placeholder="Валюта (например, ₸, ₽, $)" placeholderTextColor={theme.textMuted} value={currency} onChangeText={setCurrency} style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, }, ]} /> <TouchableOpacity onPress={handleSaveSettings} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить профиль и бюджет </Text> </TouchableOpacity> </View> {/* Тема */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Тема и вид</Text> <View style={base.row}> <Text style={[base.tinyText, { color: theme.textMuted, marginRight: 6 }]}> Тёмная тема </Text> <Switch value={theme.mode === 'dark'} onValueChange={onToggleTheme} thumbColor={theme.mode === 'dark' ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> В будущем сюда можно добавить иконки, виджеты и другие настройки UI. </Text> </View> {/* Анти-импульсная защита */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Контроль импульсных покупок</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🧠 Эксперимент</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Включите анти-импульсный режим, чтобы приложение задавало вам «контрольный вопрос» перед крупной покупкой. </Text> <View style={[base.rowBetween, { marginTop: 8 }]}> <View style={{ flex: 1 }}> <Text style={[base.smallText, { color: theme.text }]}> Анти-импульсный режим </Text> <Text style={[base.tinyText, { color: theme.textMuted }]}> Срабатывает при тратах выше порога. </Text> </View> <Switch value={antiImpulseEnabled} onValueChange={setAntiImpulseEnabled} thumbColor={antiImpulseEnabled ? theme.accent : '#9CA3AF'} trackColor={{ true: theme.accentSoft, false: theme.cardAlt }} /> </View> <TextInput placeholder="Порог крупной покупки (например, 50 000)" placeholderTextColor={theme.textMuted} value={antiImpulseThreshold} onChangeText={setAntiImpulseThreshold} keyboardType="numeric" style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> </View> {/* Безопасная заметка */} <View style={[ base.card, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }, ]} > <View style={base.cardHeaderRow}> <Text style={[base.cardTitle, { color: theme.text }]}>Безопасная заметка</Text> <Text style={[base.tinyText, { color: theme.textMuted }]}>🔐 Локально</Text> </View> <Text style={[base.tinyText, { color: theme.textMuted }]}> Заметка шифруется простым алгоритмом (XOR + HEX) с вашим PIN и хранится только в AsyncStorage. Для реального проекта используйте надёжную криптографию (AES и т.п.) и биометрию. </Text> {!noteUnlocked && ( <> {currentUser.pin ? ( <> <TextInput placeholder="Введите PIN для разблокировки" placeholderTextColor={theme.textMuted} value={pinInput} onChangeText={setPinInput} keyboardType="number-pad" secureTextEntry style={[ base.input, { backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleUnlockNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Разблокировать заметку </Text> </TouchableOpacity> </> ) : ( <> <Text style={[base.smallText, { color: theme.text, marginTop: 8 }]}> PIN не установлен при регистрации, поэтому заметка хранится без шифрования. </Text> <TouchableOpacity onPress={() => setNoteUnlocked(true)} style={[ base.button, { backgroundColor: theme.cardAlt, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: theme.text }]}> Показать заметку </Text> </TouchableOpacity> </> )} </> )} {noteUnlocked && ( <> <TextInput placeholder="Секретная финансовая заметка (пароли сюда лучше не писать)" placeholderTextColor={theme.textMuted} value={noteDraft} onChangeText={setNoteDraft} multiline style={[ base.input, { minHeight: 80, textAlignVertical: 'top', backgroundColor: theme.cardAlt, color: theme.text, borderWidth: 1, borderColor: theme.border, marginTop: 8, }, ]} /> <TouchableOpacity onPress={handleSaveNote} style={[ base.button, { backgroundColor: theme.accent, marginTop: 4 }, ]} > <Text style={[base.buttonText, { color: '#FFFFFF' }]}> Сохранить зашифровано </Text> </TouchableOpacity> </> )} </View> </ScrollView> ); }; const MainApp = ({ currentUser, userData, setUserData, onLogout, themeMode, setThemeMode, }) => { const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME; const [activeTab, setActiveTab] = useState('home'); const handleUpdateUserData = (updater) => { setUserData((prev) => { const prevVal = prev || createDefaultUserData(themeMode); const next = typeof updater === 'function' ? updater(prevVal) : updater; return next; }); }; const handleToggleTheme = () => { setThemeMode((prev) => (prev === 'dark' ? 'light' : 'dark')); }; const profile = userData.profile || {}; const avatarUri = profile.avatarUri; const displayName = profile.firstName || profile.lastName ? `${profile.firstName || ''} ${profile.lastName || ''}`.trim() : currentUser.email; const initials = profile.firstName || profile.lastName ? `${(profile.firstName || profile.lastName || 'U').charAt(0).toUpperCase()}` : currentUser.email.charAt(0).toUpperCase(); return ( <SafeAreaView style={[ base.container, { backgroundColor: theme.background }, ]} > <StatusBar barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'} /> <View style={base.header}> <View style={base.row}> {avatarUri ? ( <Image source={{ uri: avatarUri }} style={base.avatarImage} /> ) : ( <View style={[ base.avatarCircle, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.smallText, { color: theme.textMuted }]}> {initials} </Text> </View> )} <View style={{ marginLeft: 10 }}> <Text style={[base.headerTitle, { color: theme.text }]}> Мой бюджет </Text> <Text style={[base.headerSubtitle, { color: theme.textMuted }]}> {displayName} </Text> </View> </View> <View style={base.chipRow}> <View style={[ base.chip, { backgroundColor: theme.accentSoft }, ]} > <Text style={[base.chipText, { color: theme.accent }]}>🤖 ИИ-помощник</Text> </View> <TouchableOpacity onPress={handleToggleTheme} style={[ base.chip, { backgroundColor: theme.cardAlt }, ]} > <Text style={[base.chipText, { color: theme.text }]}> {theme.mode === 'dark' ? '◐' : '◑'} </Text> </TouchableOpacity> </View> </View> {activeTab === 'home' && <HomeScreen userData={userData} theme={theme} />} {activeTab === 'expenses' && ( <ExpensesScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'goals' && ( <GoalsScreen userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} /> )} {activeTab === 'ai' && <AiChatScreen userData={userData} theme={theme} />} {activeTab === 'profile' && ( <ProfileScreen currentUser={currentUser} userData={userData} onUpdateUserData={handleUpdateUserData} theme={theme} onLogout={onLogout} onToggleTheme={handleToggleTheme} /> )} <View style={[ base.tabBar, { backgroundColor: theme.card, borderTopColor: theme.border }, ]} > {[ { id: 'home', label: 'Главная', icon: '🏡' }, { id: 'expenses', label: 'Траты', icon: '💳' }, { id: 'goals', label: 'Цели', icon: '📌' }, { id: 'ai', label: 'ИИ-чат', icon: '🧠' }, { id: 'profile', label: 'Профиль', icon: '👤' }, ].map((tab) => { const isActive = activeTab === tab.id; return ( <TouchableOpacity key={tab.id} onPress={() => setActiveTab(tab.id)} style={[ base.tabButton, { backgroundColor: isActive ? theme.accentSoft : 'transparent' }, ]} > <Text style={[ { fontSize: 16 }, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.icon} </Text> <Text style={[ base.tabLabel, { color: isActive ? theme.accent : theme.textMuted }, ]} > {tab.label} </Text> </TouchableOpacity> ); })} </View> </SafeAreaView> ); }; // ---------- корневой компонент ---------- export default function App() { const [users, setUsers] = useState([]); const [session, setSession] = useState(null); const [userData, setUserData] = useState(null); const [themeMode, setThemeMode] = useState('dark'); const [loading, setLoading] = useState(true); const theme = themeMode === 'dark' ? DARK_THEME : LIGHT_THEME; useEffect(() => { const init = async () => { try { const usersJson = await AsyncStorage.getItem(STORAGE_KEYS.USERS); const sessionJson = await AsyncStorage.getItem(STORAGE_KEYS.SESSION); const loadedUsers = usersJson ? JSON.parse(usersJson) : []; setUsers(loadedUsers); if (sessionJson) { const s = JSON.parse(sessionJson); const u = loadedUsers.find((user) => user.id === s.userId); if (u) { setSession(s); const dataKey = `FINAPP_DATA_${u.id}`; const dataJson = await AsyncStorage.getItem(dataKey); if (dataJson) { const parsed = JSON.parse(dataJson); setUserData(parsed); if (parsed.settings && parsed.settings.themeMode) { setThemeMode(parsed.settings.themeMode); } } else { const defaultData = createDefaultUserData(themeMode); setUserData(defaultData); await AsyncStorage.setItem(dataKey, JSON.stringify(defaultData)); } } } } catch (e) { console.warn('Init error', e); } finally { setLoading(false); } }; init(); }, []); useEffect(() => { const persist = async () => { if (!session || !userData) return; try { const key = `FINAPP_DATA_${session.userId}`; const payload = { ...userData, settings: { ...(userData.settings || {}), themeMode, }, }; await AsyncStorage.setItem(key, JSON.stringify(payload)); } catch (e) { console.warn('Save userData error', e); } }; persist(); }, [userData, session, themeMode]); const handleLogin = async (user, profileFromRegistration = null) => { const s = { userId: user.id }; setSession(s); await AsyncStorage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(s)); const key = `FINAPP_DATA_${user.id}`; const dataJson = await AsyncStorage.getItem(key); if (dataJson) { const parsed = JSON.parse(dataJson); setUserData(parsed); if (parsed.settings && parsed.settings.themeMode) { setThemeMode(parsed.settings.themeMode); } } else { const defaultData = createDefaultUserData(themeMode, profileFromRegistration || {}); setUserData(defaultData); await AsyncStorage.setItem(key, JSON.stringify(defaultData)); } }; const handleRegister = async (newUser, profile) => { const updatedUsers = [...users, newUser]; setUsers(updatedUsers); await AsyncStorage.setItem(STORAGE_KEYS.USERS, JSON.stringify(updatedUsers)); await handleLogin(newUser, profile); }; const handleLogout = async () => { setSession(null); setUserData(null); await AsyncStorage.removeItem(STORAGE_KEYS.SESSION); }; if (loading) { return ( <SafeAreaView style={[ base.container, base.center, { backgroundColor: theme.background }, ]} > <StatusBar barStyle={theme.mode === 'dark' ? 'light-content' : 'dark-content'} /> <Text style={[base.smallText, { color: theme.textMuted }]}> Загрузка данных... </Text> </SafeAreaView> ); } if (!session || !userData) { return ( <AuthScreen users={users} onLogin={handleLogin} onRegister={handleRegister} theme={theme} /> ); } const currentUser = users.find((u) => u.id === session.userId) || { id: session.userId, email: 'user', pin: '', }; return ( <MainApp currentUser={currentUser} userData={userData} setUserData={setUserData} onLogout={handleLogout} themeMode={themeMode} setThemeMode={setThemeMode} /> ); }
Этот файл можно целиком вставить в App.js в Expo Snack. Никаких expo-mlkit-ocr и распознавания чеков тут больше нет, всё работает только на чистом JS, AsyncStorage, ImagePicker и Speech.