
Лето уже давно позади, зима на носу, а значит — самое время начинать подготовку к следующему лету. Для многих это означает одно: попытку выбраться из состояния «тюленя» хотя бы в состояние «тюленя, который слегка похудел».
Чтобы совместить полезное с полезным, заодно соберём небольшое приложение — простой трекер веса и тренировок — и посмотрим, как на практике работает мультиплатформенная разработка на React с Expo. Спойлер: почти то же самое, что и обычная разработка на React — и, похоже, именно она окончательно забивает гвоздь в гроб Dart/Flutter и прочих попыток конкурентов сделать вид, что React — это страшный сон, который можно забыть.
React Native и Expo — что это и зачем

Для начала разберёмся, что вообще происходит, зачем и почему. React Native — это фреймворк от Facebook* для создания нативных мобильных приложений с использованием JavaScript и React, появившийся в д��лёком 2015 году и с тех пор собравший вокруг себя внушительное комьюнити и экосистему инструментов. Про это, думаем, многие и так знали, а если бы нет, то спокойно узнали первым запросом в поисковике.
В отличие от веб-версии React, где мы работаем с HTML-элементами типа div, span и прочим арсеналом фронтендера, в React Native мы имеем дело с компонентами типа View, Text, TouchableOpacity и другими абстракциями, которые на выходе превращаются в нативные элементы iOS и Android, а также Web.
Expo же — это набор инструментов и сервисов, построенных вокруг React Native, который значительно упрощает процесс разработки. По сути, это что-то вроде «create-react-app» для мультиплатформеной разработки, только с расширенными возможностями: от удобного запуска приложения на реальном устройстве через QR-код до предустановленных библиотек для работы с файловой системой, камерой, уведомлениями и прочими прелестями, которые обычно требуют отдельной настройки нативных модулей.
Создаём проект и настраиваем окружение
Начать работу с Expo проще простого. Всё, что нужно — это Node.js, npm и несколько команд в консоли:
# Создаём проект напрямую
npx create-expo-app onepunchman-training
cd onepunchman-training
# Устанавливаем зависимости
npm install
npx expo install react-dom react-native-web # Чтобы можно было открывать в бразуере
# Запускаем проект
npx expo start --lan
После запуска в терминале появится QR-код, который можно отсканировать приложением Expo Go на смартфоне, и приложение сразу загрузится на ваше устройство. Никаких сборок, никаких танцев с бубном вокруг Xcode или Android Studio — просто сканируешь и работаешь.
Важный момент для тех, кто разрабатывает под Linux: если у вас Arch Linux с firewalld или другой дистрибутив с активным файрволом, не забудьте открыть порт 8081 и сделать его публичным. Под KDE Plasma это делается через графические настройки фаервола, где нужно добавить порт в категорию Public.

В консоли через firewalld это выглядит примерно так:
sudo firewall-cmd --zone=public --add-port=8081/tcp --permanent
sudo firewall-cmd --reload
Для ufw команда ещё проще:
sudo ufw allow 8081/tcp
После этого можно спокойно заходить из браузера на localhost:8081 или сканировать QR-код с Android-устройства в приложении Expo Go.

Весь код проекта вы просто переносите в созданную папку — onepunchman-training, вам нужно заменить app.json и package.json, прикреплённые к посту в свёрнутом виде. И можете ещё удалить папку app, так как Expo Router, для которого она нужна, мы не будем использовать.
App.tsx
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TouchableOpacity,
ScrollView,
StyleSheet,
TextInput,
Modal,
Switch,
Alert,
Platform,
Share
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Ionicons } from '@expo/vector-icons';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import * as DocumentPicker from 'expo-document-picker';
const App = () => {
const [darkMode, setDarkMode] = useState(false);
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
const [dailyData, setDailyData] = useState({});
const [weightData, setWeightData] = useState({});
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [showWeightModal, setShowWeightModal] = useState(false);
const [showDatePicker, setShowDatePicker] = useState(false);
const [tempWeight, setTempWeight] = useState('');
const [graphMode, setGraphMode] = useState('progress');
const today = new Date().toISOString().split('T')[0];
// Theme
const theme = darkMode ? {
bg: '#1a1a1a',
cardBg: '#2d2d2d',
text: '#ffffff',
textSecondary: '#a0a0a0',
border: '#404040',
accent: '#f0db4f',
orange: '#ff6b35',
blue: '#4169e1',
green: '#4caf50'
} : {
bg: '#f8f9fa',
cardBg: '#ffffff',
text: '#2c3e50',
textSecondary: '#7f8c8d',
border: '#e1e8ed',
accent: '#f39c12',
orange: '#ff6b35',
blue: '#4169e1',
green: '#4caf50'
};
// Load data
useEffect(() => {
loadData();
}, []);
// Save data
useEffect(() => {
saveData();
}, [dailyData, weightData, startDate, darkMode]);
useEffect(() => {
if (showWeightModal) {
const selectedWeight = weightData[selectedDate];
if (selectedWeight) {
setTempWeight(selectedWeight.toString());
} else {
const sortedDates = Object.keys(weightData).sort().reverse();
if (sortedDates.length > 0) {
setTempWeight(weightData[sortedDates[0]].toString());
} else {
setTempWeight('70');
}
}
}
}, [showWeightModal, weightData, selectedDate]);
const loadData = async () => {
try {
const savedData = await AsyncStorage.getItem('onePunchManData');
if (savedData) {
const parsed = JSON.parse(savedData);
setDailyData(parsed.dailyData || {});
setWeightData(parsed.weightData || {});
setStartDate(parsed.startDate || new Date().toISOString().split('T')[0]);
setDarkMode(parsed.darkMode || false);
}
} catch (error) {
console.log('Error loading data:', error);
}
};
const saveData = async () => {
try {
const dataToSave = {
dailyData,
weightData,
startDate,
darkMode
};
await AsyncStorage.setItem('onePunchManData', JSON.stringify(dataToSave));
} catch (error) {
console.log('Error saving data:', error);
}
};
const calculateStats = () => {
let totalPoints = 0;
let currentStreak = 0;
let lastDate = null;
const sortedDates = Object.keys(dailyData).sort();
sortedDates.forEach(date => {
const dayData = dailyData[date];
const dayPoints = (dayData.pushups ? 10 : 0) +
(dayData.situps ? 10 : 0) +
(dayData.squats ? 10 : 0) +
(dayData.running ? 10 : 0);
totalPoints += dayPoints;
if (dayPoints === 40) {
if (!lastDate || isConsecutive(lastDate, date)) {
currentStreak++;
} else {
currentStreak = 1;
}
lastDate = date;
} else if (dayPoints > 0) {
currentStreak = 0;
}
});
return { totalPoints, streak: currentStreak };
};
const isConsecutive = (date1, date2) => {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffTime = Math.abs(d2 - d1);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays === 1;
};
const getHeroRank = (points) => {
if (points >= 14600) return { rank: 'S', color: '#FFD700' };
if (points >= 10000) return { rank: 'A', color: '#FF6B35' };
if (points >= 5000) return { rank: 'B', color: '#4169E1' };
return { rank: 'C', color: '#808080' };
};
const handleCheckbox = (exercise) => {
const newData = { ...dailyData };
if (!newData[selectedDate]) {
newData[selectedDate] = {};
}
newData[selectedDate][exercise] = !newData[selectedDate][exercise];
setDailyData(newData);
};
const adjustWeight = (amount) => {
const current = parseFloat(tempWeight) || 0;
const newWeight = Math.max(0, current + amount);
setTempWeight(newWeight.toFixed(1));
};
const saveWeight = () => {
const weight = parseFloat(tempWeight);
if (!isNaN(weight) && weight > 0) {
setWeightData({
...weightData,
[selectedDate]: weight
});
setShowWeightModal(false);
}
};
const changeDate = (days) => {
const current = new Date(selectedDate);
current.setDate(current.getDate() + days);
const newDate = current.toISOString().split('T')[0];
if (newDate <= today) {
setSelectedDate(newDate);
}
};
const exportData = async () => {
try {
const dataStr = JSON.stringify({ dailyData, weightData, startDate }, null, 2);
const fileName = `onepunchman_data_${new Date().toISOString().split('T')[0]}.json`;
// Проверяем платформу
if (Platform.OS === 'web') {
// Веб-версия: используем blob и download
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
Alert.alert('Успех', 'Файл загружен!');
} else {
// Мобильная версия: используем FileSystem и Sharing
const fileUri = FileSystem.cacheDirectory + fileName;
await FileSystem.writeAsStringAsync(fileUri, dataStr);
console.log('Файл создан:', fileUri);
const isAvailable = await Sharing.isAvailableAsync();
console.log('Sharing доступен:', isAvailable);
if (isAvailable) {
await Sharing.shareAsync(fileUri, {
mimeType: 'application/json',
dialogTitle: 'Сохранить данные тренировок',
UTI: 'public.json'
});
Alert.alert('Успех', 'Выберите, куда сохранить файл');
} else {
const shareResult = await Share.share({
message: dataStr,
title: 'Данные тренировок One Punch Man'
});
if (shareResult.action === Share.sharedAction) {
Alert.alert('Успех', 'Данные отправлены!');
}
}
}
} catch (error) {
console.error('Ошибка экспорта:', error);
Alert.alert('Ошибка', `Не удалось экспортировать данные: ${error.message}`);
}
};
const importData = async () => {
try {
if (Platform.OS === 'web') {
// Веб-версия: используем input[type=file]
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = async (event) => {
try {
const imported = JSON.parse(event.target.result);
setDailyData(imported.dailyData || {});
setWeightData(imported.weightData || {});
setStartDate(imported.startDate || new Date().toISOString().split('T')[0]);
Alert.alert('Успех', 'Данные успешно импортированы!');
} catch (error) {
Alert.alert('Ошибка', 'Неверный формат файла');
}
};
reader.readAsText(file);
}
};
input.click();
} else {
// Мобильная версия: используем DocumentPicker
const result = await DocumentPicker.getDocumentAsync({
type: 'application/json',
copyToCacheDirectory: true
});
if (result.canceled === false && result.assets && result.assets[0]) {
const fileContent = await FileSystem.readAsStringAsync(result.assets[0].uri);
const imported = JSON.parse(fileContent);
setDailyData(imported.dailyData || {});
setWeightData(imported.weightData || {});
setStartDate(imported.startDate || new Date().toISOString().split('T')[0]);
Alert.alert('Успех', 'Данные успешно импортированы!');
}
}
} catch (error) {
console.error('Ошибка импорта:', error);
Alert.alert('Ошибка', `Не удалось импортировать данные: ${error.message}`);
}
};
const formatDateRu = (dateStr) => {
const date = new Date(dateStr);
const options = { day: 'numeric', month: 'long', year: 'numeric' };
return date.toLocaleDateString('ru', options);
};
const { totalPoints, streak } = calculateStats();
const { rank, color: rankColor } = getHeroRank(totalPoints);
const selectedDayData = dailyData[selectedDate] || {};
const getGraphData = () => {
const data = [];
for (let i = 29; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const dayData = dailyData[dateStr] || {};
const points = (dayData.pushups ? 10 : 0) +
(dayData.situps ? 10 : 0) +
(dayData.squats ? 10 : 0) +
(dayData.running ? 10 : 0);
const weight = weightData[dateStr] || null;
data.push({
date: date.getDate(),
dateStr,
points,
weight,
isSelected: dateStr === selectedDate
});
}
return data;
};
const graphData = getGraphData();
const maxGraphValue = graphMode === 'progress'
? 40
: Math.max(...Object.values(weightData).filter(Boolean), 100);
const minWeightValue = Math.min(...Object.values(weightData).filter(Boolean), 0);
const exercises = [
{ key: 'pushups', icon: 'fitness', label: '100 отжиманий', color: theme.orange },
{ key: 'situps', icon: 'accessibility', label: '100 приседаний', color: theme.blue },
{ key: 'squats', icon: 'body', label: '100 пресс', color: theme.green },
{ key: 'running', icon: 'walk', label: '10 км бег', color: theme.accent }
];
const dayPoints = (selectedDayData.pushups ? 10 : 0) +
(selectedDayData.situps ? 10 : 0) +
(selectedDayData.squats ? 10 : 0) +
(selectedDayData.running ? 10 : 0);
return (
<View style={[styles.container, { backgroundColor: theme.bg }]}>
<ScrollView style={styles.scrollView}>
{/* Header */}
<View style={[styles.header, { backgroundColor: theme.cardBg, borderBottomColor: theme.border }]}>
<Text style={[styles.title, { color: theme.text }]}>
? One Punch Man Training
</Text>
<TouchableOpacity onPress={() => setDarkMode(!darkMode)}>
<Ionicons
name={darkMode ? "sunny" : "moon"}
size={24}
color={theme.text}
/>
</TouchableOpacity>
</View>
{/* Stats Cards */}
<View style={styles.statsContainer}>
<View style={[styles.statCard, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
<Ionicons name="trophy" size={24} color={theme.accent} />
<Text style={[styles.statValue, { color: theme.text }]}>{totalPoints}</Text>
<Text style={[styles.statLabel, { color: theme.textSecondary }]}>Очки</Text>
</View>
<View style={[styles.statCard, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
<Text style={[styles.rankBadge, { color: rankColor, borderColor: rankColor }]}>
{rank}
</Text>
<Text style={[styles.statLabel, { color: theme.textSecondary }]}>Ранг героя</Text>
</View>
<View style={[styles.statCard, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
<Ionicons name="flame" size={24} color={theme.orange} />
<Text style={[styles.statValue, { color: theme.text }]}>{streak}</Text>
<Text style={[styles.statLabel, { color: theme.textSecondary }]}>Дней подряд</Text>
</View>
</View>
{/* Date Navigator */}
<View style={[styles.dateNavigator, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
<TouchableOpacity onPress={() => changeDate(-1)}>
<Ionicons name="chevron-back" size={28} color={theme.accent} />
</TouchableOpacity>
<View style={styles.dateInfo}>
<Text style={[styles.dateText, { color: theme.text }]}>
{formatDateRu(selectedDate)}
</Text>
<Text style={[styles.pointsText, { color: theme.accent }]}>
{dayPoints} / 40 очков
</Text>
</View>
<TouchableOpacity
onPress={() => changeDate(1)}
disabled={selectedDate === today}
style={{ opacity: selectedDate === today ? 0.3 : 1 }}
>
<Ionicons name="chevron-forward" size={28} color={theme.accent} />
</TouchableOpacity>
</View>
{/* Exercises */}
<View style={[styles.exercisesCard, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
{exercises.map((exercise) => (
<TouchableOpacity
key={exercise.key}
style={[
styles.exerciseRow,
{
backgroundColor: selectedDayData[exercise.key] ? `${exercise.color}22` : 'transparent',
borderColor: selectedDayData[exercise.key] ? exercise.color : theme.border
}
]}
onPress={() => handleCheckbox(exercise.key)}
>
<View style={styles.exerciseLeft}>
<Ionicons name={exercise.icon} size={24} color={exercise.color} />
<Text style={[styles.exerciseLabel, { color: theme.text }]}>
{exercise.label}
</Text>
</View>
<Ionicons
name={selectedDayData[exercise.key] ? "checkmark-circle" : "ellipse-outline"}
size={28}
color={selectedDayData[exercise.key] ? exercise.color : theme.textSecondary}
/>
</TouchableOpacity>
))}
</View>
{/* Weight Button */}
<TouchableOpacity
style={[styles.weightButton, { backgroundColor: theme.cardBg, borderColor: theme.border }]}
onPress={() => setShowWeightModal(true)}
>
<Ionicons name="fitness" size={24} color={theme.accent} />
<Text style={[styles.weightButtonText, { color: theme.text }]}>
{weightData[selectedDate]
? `Вес: ${weightData[selectedDate]} кг`
: 'Добавить вес'
}
</Text>
</TouchableOpacity>
{/* Graph Toggle */}
<View style={[styles.graphToggle, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
<TouchableOpacity
style={[
styles.toggleButton,
graphMode === 'progress' && { backgroundColor: theme.accent }
]}
onPress={() => setGraphMode('progress')}
>
<Text style={[
styles.toggleText,
{ color: graphMode === 'progress' ? '#000' : theme.text }
]}>
Прогресс
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.toggleButton,
graphMode === 'weight' && { backgroundColor: theme.accent }
]}
onPress={() => setGraphMode('weight')}
>
<Text style={[
styles.toggleText,
{ color: graphMode === 'weight' ? '#000' : theme.text }
]}>
Вес
</Text>
</TouchableOpacity>
</View>
{/* Graph */}
<View style={[styles.graphCard, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
<Text style={[styles.graphTitle, { color: theme.text }]}>
<Ionicons name="bar-chart" size={20} color={theme.accent} />
{' '}Последние 30 дней
</Text>
<View style={styles.graphContainer}>
{graphData.map((day, index) => {
const value = graphMode === 'progress' ? day.points : day.weight;
const maxValue = graphMode === 'progress' ? 40 : maxGraphValue;
const minValue = graphMode === 'progress' ? 0 : minWeightValue;
const height = value !== null
? ((value - minValue) / (maxValue - minValue)) * 150
: 0;
return (
<TouchableOpacity
key={index}
style={styles.graphBar}
onPress={() => setSelectedDate(day.dateStr)}
>
<View
style={[
styles.bar,
{
height: Math.max(height, value !== null ? 10 : 2),
backgroundColor: day.isSelected ? theme.accent :
graphMode === 'progress'
? (day.points === 40 ? theme.orange : day.points > 0 ? theme.blue : theme.border)
: (value !== null ? theme.accent : theme.border),
opacity: day.isSelected ? 1 : 0.8
}
]}
/>
{index % 5 === 0 && (
<Text style={[
styles.graphLabel,
{ color: day.isSelected ? theme.accent : theme.textSecondary }
]}>
{day.date}
</Text>
)}
</TouchableOpacity>
);
})}
</View>
</View>
{/* Export/Import */}
<View style={styles.actionButtons}>
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: theme.cardBg, borderColor: theme.border }]}
onPress={exportData}
>
<Ionicons name="download" size={20} color={theme.text} />
<Text style={[styles.actionButtonText, { color: theme.text }]}>Экспорт</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: theme.cardBg, borderColor: theme.border }]}
onPress={importData}
>
<Ionicons name="cloud-upload" size={20} color={theme.text} />
<Text style={[styles.actionButtonText, { color: theme.text }]}>Импорт</Text>
</TouchableOpacity>
</View>
<View style={{ height: 40 }} />
</ScrollView>
{/* Weight Modal */}
<Modal
visible={showWeightModal}
transparent
animationType="fade"
onRequestClose={() => setShowWeightModal(false)}
>
<View style={styles.modalOverlay}>
<View style={[styles.modalContent, { backgroundColor: theme.cardBg }]}>
<Text style={[styles.modalTitle, { color: theme.text }]}>
Вес на {formatDateRu(selectedDate)}
</Text>
<View style={styles.weightControls}>
<TouchableOpacity
style={[styles.weightButton, { backgroundColor: theme.accent }]}
onPress={() => adjustWeight(-0.5)}
>
<Ionicons name="remove" size={24} color="#000" />
</TouchableOpacity>
<TextInput
style={[styles.weightInput, { color: theme.text, borderColor: theme.border }]}
value={tempWeight}
onChangeText={setTempWeight}
keyboardType="numeric"
/>
<TouchableOpacity
style={[styles.weightButton, { backgroundColor: theme.accent }]}
onPress={() => adjustWeight(0.5)}
>
<Ionicons name="add" size={24} color="#000" />
</TouchableOpacity>
</View>
<View style={styles.modalButtons}>
<TouchableOpacity
style={[styles.modalButton, { backgroundColor: theme.border }]}
onPress={() => setShowWeightModal(false)}
>
<Text style={[styles.modalButtonText, { color: theme.text }]}>Отмена</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalButton, { backgroundColor: theme.accent }]}
onPress={saveWeight}
>
<Text style={[styles.modalButtonText, { color: '#000' }]}>Сохранить</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: Platform.OS === 'ios' ? 50 : 20,
borderBottomWidth: 2,
},
title: {
fontSize: 24,
fontWeight: 'bold',
},
statsContainer: {
flexDirection: 'row',
padding: 15,
gap: 10,
},
statCard: {
flex: 1,
padding: 15,
borderRadius: 15,
borderWidth: 2,
alignItems: 'center',
gap: 5,
},
statValue: {
fontSize: 24,
fontWeight: 'bold',
},
statLabel: {
fontSize: 12,
textAlign: 'center',
},
rankBadge: {
fontSize: 32,
fontWeight: 'bold',
borderWidth: 3,
borderRadius: 50,
width: 50,
height: 50,
textAlign: 'center',
lineHeight: 44,
},
dateNavigator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 15,
marginHorizontal: 15,
borderRadius: 15,
borderWidth: 2,
},
dateInfo: {
alignItems: 'center',
},
dateText: {
fontSize: 16,
fontWeight: 'bold',
},
pointsText: {
fontSize: 14,
marginTop: 5,
},
exercisesCard: {
margin: 15,
padding: 15,
borderRadius: 15,
borderWidth: 2,
gap: 10,
},
exerciseRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 15,
borderRadius: 10,
borderWidth: 2,
},
exerciseLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
exerciseLabel: {
fontSize: 16,
},
weightButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
padding: 15,
marginHorizontal: 15,
borderRadius: 15,
borderWidth: 2,
},
weightButtonText: {
fontSize: 16,
fontWeight: '600',
},
graphToggle: {
flexDirection: 'row',
margin: 15,
padding: 5,
borderRadius: 15,
borderWidth: 2,
},
toggleButton: {
flex: 1,
padding: 10,
borderRadius: 10,
alignItems: 'center',
},
toggleText: {
fontSize: 14,
fontWeight: '600',
},
graphCard: {
margin: 15,
padding: 15,
borderRadius: 15,
borderWidth: 2,
},
graphTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 15,
},
graphContainer: {
flexDirection: 'row',
height: 180,
alignItems: 'flex-end',
gap: 2,
},
graphBar: {
flex: 1,
alignItems: 'center',
gap: 5,
},
bar: {
width: '100%',
borderTopLeftRadius: 4,
borderTopRightRadius: 4,
},
graphLabel: {
fontSize: 10,
},
actionButtons: {
flexDirection: 'row',
gap: 10,
paddingHorizontal: 15,
},
actionButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: 15,
borderRadius: 15,
borderWidth: 2,
},
actionButtonText: {
fontSize: 14,
fontWeight: '600',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
width: '85%',
padding: 25,
borderRadius: 20,
},
modalTitle: {
fontSize: 18,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 20,
},
weightControls: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 15,
marginBottom: 20,
},
weightInput: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
borderWidth: 2,
borderRadius: 10,
padding: 10,
minWidth: 100,
},
modalButtons: {
flexDirection: 'row',
gap: 10,
},
modalButton: {
flex: 1,
padding: 15,
borderRadius: 10,
alignItems: 'center',
},
modalButtonText: {
fontSize: 16,
fontWeight: '600',
},
});
export default App;
package.json
{
"name": "onepunchman-training-app",
"version": "1.0.0",
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "~54.0.0",
"expo-document-picker": "~14.0.7",
"expo-file-system": "~19.0.0",
"expo-sharing": "~14.0.0",
"expo-status-bar": "~3.0.8",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-web": "^0.21.0"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@types/react": "~19.1.10",
"typescript": "^5.3.0"
},
"private": true
}
app.json
{
"expo": {
"name": "OnePunchMan Training",
"slug": "onepunchman-training",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.yourname.onepunchman"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.yourname.onepunchman"
},
"web": {
"favicon": "./assets/favicon.png"
}
}
}
Структура приложения и управление состоянием
Теперь перейдём к самому интересному — к коду. Наше приложение для отслеживания тренировок по программе «One Punch Man» будет отслеживать выполнение четырёх упражнений: 100 отжиманий, 100 прис��даний, 100 пресса и 10 км бега. За каждое упражнение начисляется 10 очков, и цель — набрать максимум очков, сохраняя серию выполненных дней.

Начнём с импортов и базовой структуры. В React Native мы не используем привычные HTML-теги, вместо этого импортируем специальные компоненты:
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TouchableOpacity,
ScrollView,
StyleSheet,
TextInput,
Modal,
Switch,
Alert,
Platform,
Share
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Ionicons } from '@expo/vector-icons';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import * as DocumentPicker from 'expo-document-picker';
View — это аналог div, Text — это единственный способ отобразить текст (нельзя просто написать текст внутри View, как в HTML), TouchableOpacity — это кнопка с визуальным откликом при нажатии, ScrollView позволяет прокручивать содержимое.
Для управления состоянием приложения используем хуки useState. Нам нужно отслеживать тёмную тему, данные о тренировках, вес, выбранную дату и несколько вспомогательных флагов для модальных окон:
const App = () => {
const [darkMode, setDarkMode] = useState(false);
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
const [dailyData, setDailyData] = useState({});
const [weightData, setWeightData] = useState({});
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [showWeightModal, setShowWeightModal] = useState(false);
const [showDatePicker, setShowDatePicker] = useState(false);
const [tempWeight, setTempWeight] = useState('');
const [graphMode, setGraphMode] = useState('progress');
const today = new Date().toISOString().split('T')[0];
Здесь мы видим, как работает управление состоянием в React: каждый useState возвращает пару — текущее значение и функцию для его обновления. При изменении любого из этих значений компонент перерисовывается автоматически.
Тёмная и светлая темы
Одна из приятных особенностей мобильных приложений — возможность переключения между тёмной и светлой темами. Вместо использования CSS-переменных или контекста, мы можем просто создать объект с цветами и менять его в зависимости от состояния:
const theme = darkMode ? {
bg: '#1a1a1a',
cardBg: '#2d2d2d',
text: '#ffffff',
textSecondary: '#a0a0a0',
border: '#404040',
accent: '#f0db4f',
orange: '#ff6b35',
blue: '#4169e1',
green: '#4caf50'
} : {
bg: '#f8f9fa',
cardBg: '#ffffff',
text: '#2c3e50',
textSecondary: '#7f8c8d',
border: '#e1e8ed',
accent: '#f39c12',
orange: '#ff6b35',
blue: '#4169e1',
green: '#4caf50'
};
Теперь во всех стилях мы можем использовать theme.bg, theme.text и так далее, и при переключении darkMode все цвета изменятся автоматически. Никаких дополнительных библиотек для управления темами не требуется — чистый JavaScript и React.
Хранение данных с AsyncStorage
Одна из первых задач, с которой сталкиваешься при разработке мобильного приложения — это сохранение данных между запусками. В веб-разработке мы бы использовали localStorage, но в React Native его нет. Вместо этого существует AsyncStorage — асинхронное хранилище ключ-значение.
Работа с ним выглядит так:
useEffect(() => {
loadData();
}, []);
useEffect(() => {
saveData();
}, [dailyData, weightData, startDate, darkMode]);
const loadData = async () => {
try {
const savedData = await AsyncStorage.getItem('onePunchManData');
if (savedData) {
const parsed = JSON.parse(savedData);
setDailyData(parsed.dailyData || {});
setWeightData(parsed.weightData || {});
setStartDate(parsed.startDate || new Date().toISOString().split('T')[0]);
setDarkMode(parsed.darkMode || false);
}
} catch (error) {
console.log('Error loading data:', error);
}
};
const saveData = async () => {
try {
const dataToSave = {
dailyData,
weightData,
startDate,
darkMode
};
await AsyncStorage.setItem('onePunchManData', JSON.stringify(dataToSave));
} catch (error) {
console.log('Error saving data:', error);
}
};
Первый useEffect срабатывает один раз при монтировании компонента и загружает сохранённые данные. Второй useEffect следит за изменениями в dailyData, weightData, startDate и darkMode, и при любом изменении автоматически сохраняет данные.
Важная деталь: AsyncStorage работает только со строками, поэтому мы используем JSON.stringify для сериализации объектов перед сохранением и JSON.parse для десериализации при загрузке.
Для локальных данных мы используем AsyncStorage, но если вы захотите синхронизировать прогресс между устройствами или сделать веб-версию трекера, понадобится сервер. На виртуальном сервере UltraVDS можно легко поднять REST API или GraphQL-сервис, чтобы ваши тренировки были доступны с любого устройства.
Вычисление статистики и рангов героев
Чтобы сделать приложение интереснее, добавим систему рангов, как в самом аниме. За каждое выполненное упражнение начисляется 10 очков, максимум 40 очков в день. На основе накопленных очков присваивается ранг от C до S:
const calculateStats = () => {
let totalPoints = 0;
let currentStreak = 0;
let lastDate = null;
const sortedDates = Object.keys(dailyData).sort();
sortedDates.forEach(date => {
const dayData = dailyData[date];
const dayPoints = (dayData.pushups ? 10 : 0) +
(dayData.situps ? 10 : 0) +
(dayData.squats ? 10 : 0) +
(dayData.running ? 10 : 0);
totalPoints += dayPoints;
if (dayPoints === 40) {
if (!lastDate || isConsecutive(lastDate, date)) {
currentStreak++;
} else {
currentStreak = 1;
}
lastDate = date;
} else if (dayPoints > 0) {
currentStreak = 0;
}
});
return { totalPoints, streak: currentStreak };
};
const getHeroRank = (points) => {
if (points >= 14600) return { rank: 'S', color: '#FFD700' };
if (points >= 10000) return { rank: 'A', color: '#FF6B35' };
if (points >= 5000) return { rank: 'B', color: '#4169E1' };
return { rank: 'C', color: '#808080' };
};
Функция calculateStats проходит по всем датам с данными, подсчитывает общие очки и определяет текущую серию дней с полным выполнением программы (все 40 очков). Для определения серии используется вспомогательная функция isConsecutive, которая проверяет, идут ли две даты подряд:
const isConsecutive = (date1, date2) => {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffTime = Math.abs(d2 - d1);
const diffDays = Math.ceil(diffTime / (1000 60 60 * 24));
return diffDays === 1;
};
Система рангов построена на пороговых значениях: ранг S требует 14600 очков (365 дней полного выполнения программы), ранг A — 10000 очков, B — 5000, и ранг C получают все остальные.
Основной интерфейс и чекбоксы упражнений
Теперь создадим интерфейс для отметки выполненных упражнений. В React Native нет стандартного компонента чекбокса, поэтому мы используем TouchableOpacity с иконкой:
const exercises = [
{ key: 'pushups', icon: 'fitness', label: '100 отжиманий', color: theme.orange },
{ key: 'situps', icon: 'accessibility', label: '100 приседаний', color: theme.blue },
{ key: 'squats', icon: 'body', label: '100 пресс', color: theme.green },
{ key: 'running', icon: 'walk', label: '10 км бег', color: theme.accent }
];
const handleCheckbox = (exercise) => {
const newData = { ...dailyData };
if (!newData[selectedDate]) {
newData[selectedDate] = {};
}
newData[selectedDate][exercise] = !newData[selectedDate][exercise];
setDailyData(newData);
};
Массив exercises описывает все упражнения с их ключами, иконками, подписями и цветами. Функция handleCheckbox переключает состояние упражнения для выбранной даты. Обратите внимание на использование spread-оператора (...) — это важно для того, чтобы React понял, что объект изменился и нужно обновить интерфейс.
Отрисовка упражнений выглядит так:
<View style={[styles.exercisesCard, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
{exercises.map(exercise => (
<TouchableOpacity
key={exercise.key}
style={[
styles.exerciseRow,
{
backgroundColor: selectedDayData[exercise.key] ? exercise.color + '20' : 'transparent',
borderColor: selectedDayData[exercise.key] ? exercise.color : theme.border
}
]}
onPress={() => handleCheckbox(exercise.key)}
>
<View style={styles.exerciseLeft}>
<Ionicons
name={exercise.icon}
size={24}
color={selectedDayData[exercise.key] ? exercise.color : theme.textSecondary}
/>
<Text style={[styles.exerciseLabel, { color: theme.text }]}>
{exercise.label}
</Text>
</View>
{selectedDayData[exercise.key] && (
<Ionicons name="checkmark-circle" size={24} color={exercise.color} />
)}
</TouchableOpacity>
))}
</View>
Здесь мы используем метод map для создания списка упражнений. Каждое упражнение представляет собой TouchableOpacity, который меняет цвет и показывает галочку при выполнении. Обратите внимание на добавление '20' к цвету для создания полупрозрачного фона — это хак для работы с прозрачностью в React Native, где нужно указывать opacity в формате RGBA или добавлять альфа-канал к hex-цвету.
График прогресса за 30 дней
Одна из ключевых фич приложения — визуализация прогресса. Создадим простой столбчатый график, показывающий набранные очки за последние 30 дней:
const getGraphData = () => {
const data = [];
for (let i = 29; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const dayData = dailyData[dateStr] || {};
const points = (dayData.pushups ? 10 : 0) +
(dayData.situps ? 10 : 0) +
(dayData.squats ? 10 : 0) +
(dayData.running ? 10 : 0);
const weight = weightData[dateStr] || null;
data.push({
date: date.getDate(),
dateStr,
points,
weight,
isSelected: dateStr === selectedDate
});
}
return data;
};
Эта функция создаёт массив из 30 элементов, каждый из которых содержит данные за один день: дату, набранные очки, вес и флаг, выбрана ли эта дата в данный момент.
Отрисовка графика делается через flexbox с выравниванием по нижнему краю:
const graphData = getGraphData();
const maxGraphValue = graphMode === 'progress'
? 40
: Math.max(...Object.values(weightData).filter(Boolean), 100);
<View style={[styles.graphCard, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
<Text style={[styles.graphTitle, { color: theme.text }]}>
? {graphMode === 'progress' ? 'Последние 30 дней' : 'График веса'}
</Text>
<View style={styles.graphContainer}>
{graphData.map((day, index) => {
const value = graphMode === 'progress' ? day.points : day.weight;
const height = value ? (value / maxGraphValue) * 100 : 0;
const barColor = graphMode === 'progress'
? (day.points === 40 ? theme.green : theme.accent)
: theme.blue;
return (
<View key={index} style={styles.graphBar}>
<View
style={[
{
height: ${height}%,
backgroundColor: day.isSelected ? theme.orange : barColor,
opacity: day.isSelected ? 1 : 0.7
}
]}
/>
<Text style={[styles.graphLabel, { color: theme.textSecondary }]}>
{day.date}
</Text>
</View>
);
})}
</View>
</View>
График может показывать либо прогресс в очках, либо изменение веса — это переключается кнопкой graphMode. Высота столбца вычисляется как процент от максимального значения, что даёт хорошую визуализацию динамики изменений.
Модальное окно для ввода веса

Для удобного ввода веса создадим модальное окно с возможностью изменения значения кнопками плюс/минус:
const adjustWeight = (amount) => {
const current = parseFloat(tempWeight) || 0;
const newWeight = Math.max(0, current + amount);
setTempWeight(newWeight.toFixed(1));
};
const saveWeight = () => {
const weight = parseFloat(tempWeight);
if (!isNaN(weight) && weight > 0) {
setWeightData({
...weightData,
[selectedDate]: weight
});
setShowWeightModal(false);
}
};
Модальное окно в React Native — это отдельный компонент Modal, который накладывается поверх основного содержимого:
<Modal
visible={showWeightModal}
transparent
animationType="fade"
onRequestClose={() => setShowWeightModal(false)}
>
<View style={styles.modalOverlay}>
<View style={[styles.modalContent, { backgroundColor: theme.cardBg }]}>
<Text style={[styles.modalTitle, { color: theme.text }]}>
Вес на {formatDateRu(selectedDate)}
</Text>
<View style={styles.weightControls}>
<TouchableOpacity
style={[styles.weightButton, { backgroundColor: theme.accent }]}
onPress={() => adjustWeight(-0.5)}
>
<Ionicons name="remove" size={24} color="#000" />
</TouchableOpacity>
<TextInput
style={[styles.weightInput, { color: theme.text, borderColor: theme.border }]}
value={tempWeight}
onChangeText={setTempWeight}
keyboardType="numeric"
/>
<TouchableOpacity
style={[styles.weightButton, { backgroundColor: theme.accent }]}
onPress={() => adjustWeight(0.5)}
>
<Ionicons name="add" size={24} color="#000" />
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
Компонент TextInput с параметром keyboardType="numeric" открывает цифровую клавиатуру на мобильном устройстве, что делает ввод веса более удобным.
Экспорт и импорт данных
Важный функционал любого трекера — возможность сохранить свои данные и перенести их на другое устройство. Реализация адаптирована под разные платформы с помощью Platform.OS:

const exportData = async () => {
try {
const dataStr = JSON.stringify({ dailyData, weightData, startDate }, null, 2);
const fileName = onepunchman_data_${new Date().toISOString().split('T')[0]}.json;
// Проверяем платформу
if (Platform.OS === 'web') {
// Веб-версия: используем blob и download
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
Alert.alert('Успех', 'Файл загружен!');
} else {
// Мобильная версия: используем FileSystem и Sharing
const fileUri = FileSystem.cacheDirectory + fileName;
await FileSystem.writeAsStringAsync(fileUri, dataStr);
const isAvailable = await Sharing.isAvailableAsync();
if (isAvailable) {
await Sharing.shareAsync(fileUri, {
mimeType: 'application/json',
dialogTitle: 'Сохранить данные тренировок'
});
}
}
} catch (error) {
Alert.alert('Ошибка', Не удалось экспортировать: ${error.message});
}
};
На мобильных устройствах FileSystem.writeAsStringAsync (из expo-file-system/legacy) записывает данные в JSON-файл в кэш-директории. Затем Sharing.shareAsync открывает системное меню «Поделиться», позволяя сохранить файл в Google Drive, отправить в мессенджеры, по почте или любым другим способом.
В веб-версии данные конвертируются в Blob, создаётся временная download-ссылка, и файл автоматически скачивается в папку загрузок браузера. После скачивания ссылка удаляется для освобождения памяти.
Импорт данных работает аналогично: на мобильных DocumentPicker.getDocumentAsync открывает нативный файловый менеджер, а в браузере создаётся невидимый <input type="file"> для выбора JSON-файла. В обоих случаях после выбора файл считывается и данные восстанавливаются через setDailyData, setWeightData и setStartDate.
Экспорт и импорт JSON-файлов работает на мобильных и в вебе, но если вы планируете хранить данные в облаке и делиться ими с друзьями, виртуальный сервер UltraVDS отлично справится с этим: быстрый SSD, минимальная задержка и поддержка всех популярных стеков. Так ваши данные будут доступны с любого устройства, без сложной настройки серверов.
Стилизация в React Native
Стили в React Native описываются с помощью StyleSheet.create, что даёт некоторые преимущества перед plain objects: валидацию, оптимизацию и возможность переиспользования:
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: Platform.OS === 'ios' ? 50 : 20,
borderBottomWidth: 2,
},
statsContainer: {
flexDirection: 'row',
padding: 15,
gap: 10,
},
// ... остальные стили
});
Синтаксис очень похож на CSS, но с camelCase вместо kebab-case: backgroundColor вместо background-color, flexDirection вместо flex-direction. Числовые значения указываются без единиц измерения — в React Native используются независимые от плотности пиксели (dp на Android, points на iOS).
Особенность: Platform.OS позволяет задавать разные значения для iOS и Android. В данном случае мы добавляем больший отступ сверху для iOS, чтобы контент не перекрывался статус-баром.
Навигация по датам

Для удобной навигации по дням создадим компонент с кнопками назад/вперёд:
const changeDate = (days) => {
const current = new Date(selectedDate);
current.setDate(current.getDate() + days);
const newDate = current.toISOString().split('T')[0];
if (newDate <= today) {
setSelectedDate(newDate);
}
};
const formatDateRu = (dateStr) => {
const date = new Date(dateStr);
const options = { day: 'numeric', month: 'long', year: 'numeric' };
return date.toLocaleDateString('ru', options);
};
Функция changeDate принимает количество дней для смещения (положительное или отрицательное число) и проверяет, что новая дата не превышает сегодняшнюю — нельзя отмечать упражнения в будущем. formatDateRu форматирует дату в читаемый русский формат типа «3 ноября 2025 г.».
Отрисовка навигатора:
<View style={[styles.dateNavigator, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
<TouchableOpacity onPress={() => changeDate(-1)}>
<Ionicons name="chevron-back" size={24} color={theme.accent} />
</TouchableOpacity>
<View style={styles.dateInfo}>
<Text style={[styles.dateText, { color: theme.text }]}>
{formatDateRu(selectedDate)}
</Text>
<Text style={[styles.pointsText, { color: theme.accent }]}>
{dayPoints} / 40 очков
</Text>
</View>
<TouchableOpacity
onPress={() => changeDate(1)}
disabled={selectedDate === today}
>
<Ionicons
name="chevron-forward"
size={24}
color={selectedDate === today ? theme.border : theme.accent}
/>
</TouchableOpacity>
</View>
Кнопка «вперёд» становится неактивной (disabled), когда выбрана сегодняшняя дата, и визуально это подчёркивается изменением цвета иконки.
Отображение статистики в шапке

В верхней части экрана показываем три карточки со статистикой (общие очки, ранг героя и текущую серию дней):
const { totalPoints, streak } = calculateStats();
const { rank, color: rankColor } = getHeroRank(totalPoints);
<View style={styles.statsContainer}>
<View style={[styles.statCard, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
<Text style={[styles.statValue, { color: theme.accent }]}>
? {totalPoints}
</Text>
<Text style={[styles.statLabel, { color: theme.textSecondary }]}>
Очки
</Text>
</View>
<View style={[styles.statCard, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
<Text style={[styles.rankBadge, { color: rankColor, borderColor: rankColor }]}>
{rank}
</Text>
<Text style={[styles.statLabel, { color: theme.textSecondary }]}>
Ранг героя
</Text>
</View>
<View style={[styles.statCard, { backgroundColor: theme.cardBg, borderColor: theme.border }]}>
<Text style={[styles.statValue, { color: theme.orange }]}>
? {streak}
</Text>
<Text style={[styles.statLabel, { color: theme.textSecondary }]}>
Дней подряд
</Text>
</View>
</View>
Каждая карточка получает свой цвет акцента: жёлтый для очков, динамический цвет ранга для значка героя, оранжевый для серии. Это помогает быстро ориентироваться в интерфейсе.
Что в итоге
React Native с Expo предоставляет достаточно низкий порог входа для веб-разработчиков, уже знакомых с React. Основные отличия сводятся к замене HTML-тегов на специальные компоненты и некоторым особенностям стилизации. Вся остальная логика — хуки, управление состоянием, жизненный цикл компонентов — работает точно так же, как в обычном React.
Конечно, для серьёзных приложений может потребоваться написание нативных модулей или использование более продвинутых инструментов для навигации, управления состоянием и работы с API. Но для быстрого старта и создания MVP/Демок Expo подходит на ура, по крайней мере, для человека, который о мультиплатформенной разработке знает только то, что она существует, и которому в процессе не захотелось лезть на стену от каких-либо трудностей.
*Принадлежит компании Meta, признанной экстремистской и запрещенной в России.
Комментарии (3)

GeoGGM
25.11.2025 10:41В целом по верхнему appBar'у стало ясно о опыте автора в мобильной разработке.
Еще гроб какой-то принес, гвозди...
Прикольно, конечно, пока не запустишь проект на другой ОСи и начнется вся 'сказка' нативных компонентов РН)

dsrk_dev
25.11.2025 10:41Понятно что блог компании, плюсы у статьи накинуты сотрудниками, но можно же было хотяб код нормально в статье нормально отформатировать?
Скрытый текст

yarkov
Я верю, что наступит время, когда люди запомнят, что тренировки это вообще не про похудение ))