Недавно я получил неожиданное письмо от 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! С меня пончик за самые яркие сообщения.