Недавно я получил неожиданное письмо от Google:
"Разработчик вашего аккаунта не используется и может быть закрыт..."
Аккаунт я создал ещё будучи студентом, чтобы выложить несколько небольших проектов. Но с тех пор не публиковал ничего нового, и теперь Google предупредил, что у меня есть 60 дней, чтобы что-то выпустить, иначе аккаунт будет удалён. Потерять его не хотелось — всё же какая-никакая история.
"Ладно, — подумал я, — выкачу что-нибудь быстро. На выходные. За 10 минут!"
С этого и началось история создания этого Open Source. Сначала я просто хотел обновить старое приложение, но забыл, как писать на React Native. Потом захотел добавить чуть больше функционала... А потом полностью переделал интерфейс, логику и в итоге запилил open-source крипто-приложение с генерацией визуальных портфелей на пончиках и промтов.
Как это работает? Приложение обращается к CoinGecko API и получает список криптовалют, из которых случайным образом выбираются монеты. Далее происходит диверсификация: пользователь вводит, сколько у него денег, а портфель автоматически распределяется по процентам от этой суммы. Каждая монета визуализируется в виде собственного пончика. После этого, по нажатию на кнопку Prompt, генерируется запрос для ChatGPT или других LLM-моделей, чтобы получить анализ или ответ на конкретные вопросы.
Уровень риска инвестирования в этот токен (низкий/средний/высокий) и почему.
Потенциал прибыли — хорошая ли это возможность покупки или нет?
Стои�� ли покупать сейчас, подождать или уже слишком поздно входить?
Насколько популярен/надежен и безопасен токен (на основе его рыночной капитализации, рейтинга, объема торгов и структуры предложения)?
Краткая, действенненная рекомендация по каждой монете.
И по классике, реализовано сохранение истории и возможность делиться своим портфелем с друзьями.

В этой статье я расскажу, как устроено приложение внутри, с какими граблями я столкнулся при сборке на Expo + React Native, и как в итоге получилось у меня сохранить аккаунт разработчика или нет.
Техническое описание
⚠️ Осторожно: дальше много кода, написанного в потоке сознания. Если хотите узнать, что из этого вообще получилось — листайте в конец статьи.

Для создания приложения понадобятся Node.js (версии 14 или выше), Android Studio (для Android) или Xcode (для iOS), а также React Native с Expo. Также потребуется EAS Build — для сборки под Android. Весь код доступен на GitHub. Если вы никогда не работали с npm, но очень хочется — просто выполните:
git clone <project> сd <project> npm install npm run android или npm run ios
Первое, от чего мне захотелось избавиться, — это старый Splash-экран. Я заменил его на однородный фон и добавил экран входа.
Для этого установим:
npm install react-native-responsive-screen
Затем создаём файл SplashScreen.tsx:
import { useRef } from 'react'; import { View, Text, Image, StyleSheet, TouchableOpacity, Animated } from 'react-native'; import { widthPercentageToDP as wp, heightPercentageToDP as hp } from 'react-native-responsive-screen'; const SplashScreen = ({ onHide }) => { const fadeAnim = useRef(new Animated.Value(1)).current; const scaleAnim = useRef(new Animated.Value(1)).current; // Анимация после нажатия по кнопке const handleGetStarted = () => { Animated.parallel([ Animated.timing(fadeAnim, { toValue: 0, duration: 500, useNativeDriver: true, }), Animated.timing(scaleAnim, { toValue: 2, // можно уменьшить до 0.5 или 0.1, если хочешь схлопывание, но у меня увеличение duration: 500, useNativeDriver: true, }), ]).start(() => onHide()); }; // Отрисовываем View return ( <Animated.View style={[styles.container, { opacity: fadeAnim, transform: [{ scale: scaleAnim }] }]}> <View> <Image source={require('./assets/donuts/background.png')} style={{ marginTop: hp('4.76%'), width: wp('109%'), height: hp('57.14%') }} resizeMode="contain"> </Image> </View> <View style={styles.content}> <Text style={styles.title}>Doughfolio</Text> <Text style={styles.description}> Visualize your crypto portfolio with delicious donut charts </Text> </View> <TouchableOpacity style={styles.button} onPress={handleGetStarted}> <Text style={styles.buttonText}>Get Started</Text> </TouchableOpacity> </Animated.View> ); }; const styles = StyleSheet.create({ container: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: '#FFD8DF', justifyContent: 'center', alignItems: 'center', zIndex: 1000, }, content: { flex: 1, justifyContent: 'flex-end', alignItems: 'flex-start', paddingHorizontal: wp('7.27%'), marginBottom: hp('7.61%'), }, title: { fontSize: wp('14%'), fontWeight: 'bold', color: '#FF6E76', marginBottom: hp('1.9%'), textAlign: 'left' }, description: { fontSize: wp('5.09%'), color: '#FF6E76', textAlign: 'left', }, button: { backgroundColor: 'white', borderRadius: 28, // Тень для Android elevation: 10, // Тень для iOS shadowColor: '#9B8084', shadowOffset: { width: 5, height: 5 }, shadowOpacity: 0.1, shadowRadius: 10, paddingHorizontal: wp('14.72%'), paddingVertical: hp('2%'), width: '80%', marginBottom: hp('5.71%'), alignItems: 'center', justifyContent: 'center' }, buttonText: { color: 'black', textTransform: 'uppercase', fontSize: wp('4.72%'), fontWeight: 'bold', }, }); // Ставим export, чтобы экспортировать SplashScreen в другие App.tsx export default SplashScreen;
При желании можно пойти дальше и добавить анимацию на Splash-экран с помощью Lottie. Для этого берём текущий код и дополняем его Lottie-анимацией.
На сайте LottieFiles представлен большой выбор бесплатных анимаций. Для загрузки потребуется регистрация. Выбираем понравившуюся анимацию, открываем её как проект и экспортируем в формате JSON. Затем устанавливаем Lottie:
npm install lottie-react-native
После этого подключаем анимацию во View. В App.tsx будет, например, такие строки:
export default function App() { const [splashVisible, setSplashVisible] = useState(true); const [fontsLoaded] = useFonts({ 'Roboto-Bold': require('./src/assets/fonts/Roboto-Bold.ttf'), 'Roboto-Light': require('./src/assets/fonts/Roboto-Light.ttf'), }); return ( <View style={{ flex: 1 }}> <DonutChartContainer /> {splashVisible && ( <SplashScreen onHide={() => setSplashVisible(false)} /> )} </View> ); }
По сути, мы отображаем два элемента: DonutChartContainer и SplashScreen. При этом SplashScreen наклад��вается поверх DonutChartContainer и скрывается при нажатии. Реализуется с помощью изменения состояния splashVisible через setSplashVisible.
Далее — один из интересных моментов: получение данных, непосредственно в App.tsx:
// Функция для получения данных с CoinGecko API async function fetchCryptoData() { const response = await fetch('https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=200&page=1'); const data = await response.json(); return data; } // Функция для случайного выбора 10 криптовалют function getRandomCryptos(data, count = 10, maxIndex = 200) { const minSlice = Math.floor(Math.random() * (maxIndex - count + 1)); // from 0 to 90 const maxSlice = minSlice + count; return data.slice(minSlice, maxSlice); } const handleHistorySelect = (item: any) => { setData(item.data); // Обновляем totalValue через withTiming для плавной анимации totalValue.value = withTiming(item.totalValue, { duration: 500 }); // Пересчитываем проценты decimals.value = item.data.map(crypto => crypto.percentage / 100); }; // // РАЗРЫВ ШАБЛОНА // try { // Перемешиваем картинки пончиков setImages(getShuffledDonutImages()); // Шаг 1: Получаем данные с API const cryptoData = await fetchCryptoData(); // Шаг 2: Случайным образом выбираем 10 криптовалют const selectedCryptos = getRandomCryptos(cryptoData, 10); // Шаг 3: Генерируем случайные числа для распределения весов const generateNumbers = generateRandomNumbers(n, amount); // Вычисляем общую сумму этих чисел const total = generateNumbers.reduce((acc, currentValue) => acc + currentValue, 0); // Вычисляем проценты для каждого числа const generatePercentages = calculatePercentage(generateNumbers, total); // Округляем проценты и делаем их в формате 0.00 const generateDecimals = generatePercentages.map((number) => { if (number != null && !isNaN(number)) { return Number(number.toFixed(0)) / 100; } return 0; // Да из API могут быть значения с null }); totalValue.value = withTiming(total, { duration: 1000 }); decimals.value = [...generateDecimals]; // Генерируем массив объектов с данными const arrayOfObjects = generateNumbers.map((value, index) => ({ name: selectedCryptos[index].name, image: selectedCryptos[index].image, symbol: selectedCryptos[index].symbol, minPrice: selectedCryptos[index].ath, maxPrice: selectedCryptos[index].atl, price: selectedCryptos[index].current_price, marketCap: selectedCryptos[index].market_cap, marketCapChangePercentage24h: selectedCryptos[index].market_cap_change_percentage_24h, priceChangePercentage24h: selectedCryptos[index].price_change_percentage_24h, circulatingSupply: selectedCryptos[index].circulating_supply, maxSupply: selectedCryptos[index].max_supply, totalVolume: selectedCryptos[index].total_volume, value, percentage: generatePercentages[index], decimals: generateDecimals[index] / 100, color: colors[index], // Генерация случайного цвета url: 'https://www.coingecko.com/en/coins/' + selectedCryptos[index].id, })); // Выводим данные в консоль setData(arrayOfObjects); await addToHistory(arrayOfObjects); // Сохраняем данные + общую сумму } catch (error) { console.error('Failed to data generation:', error); }
Обратили внимание на setData? Работает это следующим образом: при инициализации в DonutChartContainer создаётся состояние:
const [data, setData] = useState<Data[]>([]);
Когда вызывается setData, вы буквально обновляете значение переменной data. Далее это значение используется для визуализации и взаимодействия с данными внутри приложения. Кстати, возможно, вы также заметили функцию addToHistory. Она используется для сохранения истории портфелей. Реализована она в файле useHistory.ts. Перед этим нужно установить зависимость:
npm install @react-native-async-storage/async-storage
Данные хранятся локально в AsyncStorage под ключом cryptoDonutHistory.
// src/hooks/useHistory.ts import AsyncStorage from '@react-native-async-storage/async-storage'; import { useState, useEffect } from 'react'; interface HistoryItem { date: string; data: any[]; totalValue: number; // Добавим общую сумму } export const useHistory = () => { const [history, setHistory] = useState<HistoryItem[]>([]); const loadHistory = async () => { // Загружаем историю try { const saved = await AsyncStorage.getItem('cryptoDonutHistory'); if (saved) { const parsed = JSON.parse(saved); setHistory(parsed); } } catch (e) { console.error('Failed to load history', e); } }; interface HistoryItem { date: string; data: { name: string; // Название value: number; // Соимость в $ percentage: number; // Доля color: string; image: string; // Картинка моенты symbol: string; // Крипта имеет обазночение }[]; totalValue: number; } const clearHistory = async () => { // Очищение AsyncStorage для cryptoDonutHistory try { await AsyncStorage.removeItem('cryptoDonutHistory'); setHistory([]); return true; } catch (e) { console.error('Cleaning error:', e); return false; } }; const addToHistory = async (newData: any[]) => { // Добавляем данные в историю const totalValue = newData.reduce((sum, item) => sum + item.value, 0); try { const newItem = { date: new Date().toLocaleString(), data: newData, totalValue, }; const updatedHistory = [newItem, ...history]; await AsyncStorage.setItem('cryptoDonutHistory', JSON.stringify(updatedHistory)); setHistory(updatedHistory); } catch (e) { console.error('Failed to save history', e); } }; useEffect(() => { loadHistory(); }, []); return { history, addToHistory, clearHistory }; };
Ещё один полезный момент — это работа с форматированием чисел. В США и Европе числа записываются по-разному. Например, $324,654,765 в американской системе означает триста двадцать четыре миллиона, но для пользователя из стран, где запятая используется как десятичный разделитель, это может выглядеть как $324 с дробной частью 654 — что вводит в заблуждение.
Кроме того, точка как разделитель не всегда хорошо различима визуально, особенно на мобильных экранах.
Чтобы избежать путаницы и сделать отображение более понятным для всех пользователей, я решил форматировать числа на основе локали устройства, чттобы адаптировать отображение чисел под привычный формат пользователя. Функция formatNumber автоматически определяет локаль (undefined = текущая локаль пользователя) и форматирует число согласно её правилам — будь то американская, европейская или любая другая.
export const formatNumber = (value, { isCurrency = false, currency = 'USD', minimumFractionDigits = 0, maximumFractionDigits = 2 } = {}) => { if (value == null || isNaN(value)) return '∞'; return value.toLocaleString(undefined, { style: isCurrency ? 'currency' : 'decimal', currency, minimumFractionDigits, maximumFractionDigits, }); }; // Также используется safeToFixed, чтобы безопасно округлить числа export const safeToFixed = (value, decimals = 2) => { if (isNaN(value) || value == null) { // Если значение не число или null, возвращаем строку с дефолтным значением return '0.00'; } return value.toFixed(decimals); }
На этом с разбором кода всё — именно этими моментами мне хотелось поделиться и оставить небольшие заметки. Остальной код вы можете посмотреть в полном виде на GitHub.
Оформление
Оставшиеся 4 часа до полуночи я потратил на оформление. Нашёл самые аппетитные пончики, которые мне реально зашли, и создал слайды для отображения приложения в Google Play. Картинки искал на Vecteezy с бесплатной лицензией, а отрисовывал всё это дело в Corel Vector. Вот, что из этого вышло!

Мои «10 минут» переросли в сутки работы. Загрузил приложение в Google Play Market после полуночи, дождался проверки и лёг спать с мыслью, что следующий апдейт меня не коснётся ещё долго, и я снова смогу забыть про React Native. Вот здесь приложение в Play Market. Даже мысленно прикинул, что через полгода добавлю несколько фич, чтобы удержать аккаунт: поиск точек продажи пончиков на карте с навигатором, интерфейс для общения с разными LLM прямо в приложении, инвестиционный калькулятор… Да ещё, может, расширю данные до MOEX.

Просыпаюсь на следующее утро, а там меня встречает красивая красная плашка в аккаунте: «Аккаунт разработчика может быть заблокирован из-за отсутствия активности». Google, ты что?! Я думал, ты понимаешь, как это работает!
Сведения о проблеме из Google Play Console
Если вы планируете публиковать или обновлять приложения в будущем, выполните указанные ниже действия.
Подтвердите адрес электронной почты и номер телефона на странице "Сведения об аккаунте", если вы ещё этого не сделали.
Создайте и опубликуйте приложение или выпустите обновление для приложения в Google Play. Подробнее…
Адрес электронной почты и номер телефона на странице "Сведения об аккаунте" есть, значит от ме��я хотят обновление приложения или публикацию нового.

В итоге — потерянный день, но зато теперь у меня есть Open Source проект, аккаунт разработчика, возможно, скоро сгорит, и парочка интересных идей на будущее. Делитесь своими факапами с Google, если такие есть. Оставляйте комментарии и присоединяйтесь к Open Source! С меня пончик за самые яркие сообщения.
Финал истории
Ура! Баннер «Ваш аккаунт разработчика неактивен и может быть закрыт» наконец-то исчез. Я считаю это маленькой, но значимой победой!
