Лето уже давно позади, зима на носу, а значит — самое время начинать подготовку к следующему лету. Для многих это означает одно: попытку выбраться из состояния «тюленя» хотя бы в состояние «тюленя, который слегка похудел».

Чтобы совместить полезное с полезным, заодно соберём небольшое приложение — простой трекер веса и тренировок — и посмотрим, как на практике работает мультиплатформенная разработка на 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 очков, и цель — набрать максимум очков, сохраняя серию выполненных дней.

Приложение, открытое на Android через Expo Go
Приложение, открытое на Android через Expo Go

Начнём с импортов и базовой структуры. В 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, признанной экстремистской и запрещенной в России.

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


  1. yarkov
    25.11.2025 10:41

    Для многих это означает одно: попытку выбраться из состояния «тюленя» хотя бы в состояние «тюленя, который слегка похудел».

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


  1. GeoGGM
    25.11.2025 10:41

    В целом по верхнему appBar'у стало ясно о опыте автора в мобильной разработке.
    Еще гроб какой-то принес, гвозди...

    Прикольно, конечно, пока не запустишь проект на другой ОСи и начнется вся 'сказка' нативных компонентов РН)


  1. dsrk_dev
    25.11.2025 10:41

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

    Скрытый текст