I got a surprise email from Google recently:
“Your developer account is inactive and may be closed…”
I created this account back when I was still a student, mainly to publish a couple of small side projects. Then I kind of forgot about it. Years passed. Now Google warned me I had 60 days to publish something new — or the account would be deleted. And honestly? I didn’t want to lose it. It held a little piece of my dev journey.
"Okay," I thought. "I’ll whip something up real quick. Should take, like, 10 minutes!"
And that’s how I ended up building a full open-source crypto portfolio app — with donuts and LLM generated prompts. Here’s how it all spiraled out of control.
From Quick Fix to Full-on Crypto App
My first plan? Just update an old app. But I had forgotten how to React Native. Then I figured, hey, maybe I’ll add some features. A bit of design polish. A few UX tweaks…
One weekend later, I’d rebuilt the UI from scratch, rewritten all the logic, and ended up with a neat little open-source app that builds randomized crypto portfolios and generates ChatGPT prompts for each coin.
How It Works
- The app fetches crypto data from the CoinGecko API
- Random coins are selected and diversified based on how much money you have
- Each coin is displayed as a donut chart
- You tap a button and get a prompt for ChatGPT or any LLM, like:
- What's the investment risk level for this token (low/medium/high) and why?
- Does it have good profit potential?
- Is now a good time to buy, or should I wait?
- How reliable/safe is the token based on its market cap, volume, and supply structure?
- Give a short, actionable recommendation.
And yep, the app saves your portfolio history and lets you share it with friends. I was inspired during the creation by this picture:
Under the Hood
⚠️ Warning: there's a lot of stream-of-consciousness code below. If you want to know what came out of it, scroll to the end of the article.
What was going through my head while coding:
I’ll walk you through how I built it, the traps I ran into with Expo + React Native, and whether I actually managed to save my developer account in the end.
Requirements
You’ll need:
- Node.js (v14+)
- Android Studio (for Android) or Xcode (for iOS)
- React Native with Expo
- And EAS Build for creating Android bundles
- The full source is on GitHub.
If you’re a total npm newbie, don’t worry — just run:
git clone <project>
сd <project>
npm install
npm run android or npm run ios
Goodbye Old Splash Screen
The first thing I ditched was the outdated splash screen. I replaced it with a clean background and a login screen. Installed the needed packages and created a new SplashScreen.tsx.
npm install react-native-responsive-screen
And code in 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;
// Animation after clicking a button
const handleGetStarted = () => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 500,
useNativeDriver: true,
}),
Animated.timing(scaleAnim, {
toValue: 2, // you can reduce it to 0.5 or 0.1 if you want a collapse, but I have an increase
duration: 500,
useNativeDriver: true,
}),
]).start(() => onHide());
};
// Draw 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,
// Shadow for Android
elevation: 10,
// Shadow for 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',
},
});
// We set export to export SplashScreen to App.tsx
export default SplashScreen;
Feeling fancy? You can even add Lottie animations. Just grab a JSON animation from LottieFiles (you'll need an account), install Lottie, and embed the animation in your view.
npm install lottie-react-native
And integrate LottieView:
<LottieView source={require('./assets/animation.json')} autoPlay loop />
In App.tsx, I layered the SplashScreen over the main content and used splashVisible state to toggle its visibility.
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>
);
}
Coin Data + Donuts
The interesting part starts in App.tsx, where the app fetches data:
// Function for getting data from 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;
}
// Function to randomly select 10 cryptocurrencies
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);
// Updating totalValue via withTiming for smooth animation
totalValue.value = withTiming(item.totalValue, { duration: 500 });
// Recalculating percentages
decimals.value = item.data.map(crypto => crypto.percentage / 100);
};
////////////////////////////
// BREAKING THE TEMPLATE //
///////////////////////////
try {
// Mix up the donut pictures
setImages(getShuffledDonutImages());
// Step 1: Get data from the API
const cryptoData = await fetchCryptoData();
// Step 2: Randomly select 10 cryptocurrencies
const selectedCryptos = getRandomCryptos(cryptoData, 10);
// Step 3: Generate random numbers for weight distribution
const generateNumbers = generateRandomNumbers(n, amount);
// Calculate the total sum of these numbers
const total = generateNumbers.reduce((acc, currentValue) => acc + currentValue, 0);
// Calculate percentages for each number
const generatePercentages = calculatePercentage(generateNumbers, total);
// Round off percentages and make them in the format 0.00
const generateDecimals = generatePercentages.map((number) => {
if (number != null && !isNaN(number)) {
return Number(number.toFixed(0)) / 100;
}
return 0; // Yes, there can be null values from the API
});
totalValue.value = withTiming(total, { duration: 1000 });
decimals.value = [...generateDecimals];
// Generate an array of objects with data
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], // Random color generation
url: 'https://www.coingecko.com/en/coins/' + selectedCryptos[index].id,
}));
// Output data to the console
setData(arrayOfObjects);
await addToHistory(arrayOfObjects); // Save data + total amount
} catch (error) {
console.error('Failed to data generation:', error);
}
When setData is called, we’re updating the data state inside the DonutChartContainer — which then redraws the chart.
const [data, setData] = useState<Data[]>([]);
Bonus: there’s also a useHistory.ts hook that saves every generated portfolio to AsyncStorage under the key cryptoDonutHistory. Firstly installation:
npm install @react-native-async-storage/async-storage
And code of useHistory.ts:
// 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; // Price in $
percentage: number;
color: string;
image: string; // Image for coin
symbol: string; // Symbol of coin
}[];
totalValue: number;
}
const clearHistory = async () => { // Clear AsyncStorage for cryptoDonutHistory
try {
await AsyncStorage.removeItem('cryptoDonutHistory');
setHistory([]);
return true;
} catch (e) {
console.error('Cleaning error:', e);
return false;
}
};
const addToHistory = async (newData: any[]) => { // Added data in AsyncStorage
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 };
};
Localized Number Formatting
In the US, we write numbers like $324,654,765. But in many other countries, the comma is a decimal separator. Which means your users might read that as $324 and some cents. Not ideal.
To fix this, I formatted all numbers using the device’s locale — so users see values in the format they expect.
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,
});
};
// Also uses safeToFixed to safely round numbers.
export const safeToFixed = (value, decimals = 2) => {
if (isNaN(value) || value == null) {
// If the value is not a number or null, return a string with the default value
return '0.00';
}
return value.toFixed(decimals);
}
The Finishing Touches
With 4 hours left before midnight, I focused on the visuals. I found the tastiest-looking donut images on Vecteezy (free license), designed some Play Store slides in Corel Vector, and uploaded everything.
I hit “Submit” just after midnight, watched the build go through, and went to bed thinking I was safe from the next inactivity purge for a while. Here’s the app on Google Play if you're curious.
But Then, Google Strikes Again
Next morning?
“Your developer account may be at risk due to inactivity.”
Come on, Google! I just published an app! I followed the rules!
In the Play Console, the message basically said:
- Confirm your email and phone number (✅ done)
- Publish a new app or release an update (✅ also done)
So yeah. Either they didn’t register my app as “active” yet, or I’ll need to wait and see if the red banner disappears. But hey — I now have:
- An open-source project I’m proud of
- A maybe-doomed dev account
- And a ton of ideas for the next version
Thinking of adding a map with donut shops, built-in LLM chat, and even more data in the future. I'm planning how I'll implement new features:
Let’s Trade Stories
Got your own horror story about Google Play or dev accounts going rogue? Drop it in the comments. Share your thoughts, join the open-source repo — and if your story’s spicy enough, I owe you a donut.
GitHub repo: https://github.com/wladradchenko/doughfolio.wladradchenko.ru
Google Play: https://play.google.com/store/apps/details?id=com.wladradchenko.donut
Made with: ☕, 🍩, and 😅
Top comments (1)
Hooray! The "Your developer account is inactive and may be closed" banner is finally gone.
I consider this a tiny but meaningful victory! 🎉
Some comments may only be visible to logged-in visitors. Sign in to view all comments.