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

Комментарии (5)