
Лето уже давно позади, зима на носу, а значит — самое время начинать подготовку к следующему лету. Для многих это означает одно: попытку выбраться из состояния «тюленя» хотя бы в состояние «тюленя, который слегка похудел».
Чтобы совместить полезное с полезным, заодно соберём небольшое приложение — простой трекер веса и тренировок — и посмотрим, как на практике работает мультиплатформенная разработка на 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={[ styles.bar, { 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, признанной экстремистской и запрещенной в России.
