import AsyncStorage from "@react-native-async-stor...
تم الإنشاء في: ٤ ديسمبر ٢٠٢٥
تمت الإجابة باستخدام GPT-5.1 Thinking بواسطة Chat01
تم الإنشاء في: ٤ ديسمبر ٢٠٢٥
تمت الإجابة باستخدام GPT-5.1 Thinking بواسطة Chat01
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Dimensions,
Image,
ImageBackground,
SafeAreaView,
ScrollView,
StatusBar,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
const { width } = Dimensions.get("window");
/* ----------------------------- Constants / Mock data ----------------------------- */
const TAX_RATE = 0.12;
const STORAGE_KEYS = {
TOKEN: "app_token",
FAVORITES: "favorites_v1",
CART: "cart_v1",
SETTINGS: "app_settings_v1",
};
const FALLBACK_ARTICLES = [
{
id: "1",
title: "Прожить осень с удовольствием книга",
date: "04.09.2022",
image:
"https://adebiportal.kz/storage/tmp/resize/news/1200_0_0f7ac697e1d82f1749a9d7eba78bd14d.jpeg",
text: "Осень в этом году подкралась незаметно и стала для многих сюрпризом. Вот советы чтобы адаптироваться.",
},
{
id: "2",
title: "10 привычек яркой жизни",
date: "12.08.2023",
image:
"https://mirpozitiva.ru/wp-content/uploads/2019/11/1456737188_yarko.jpg",
text: "Маленькие привычки, которые делают каждый день приятнее.",
},
{
id: "3",
title: "Книга которая поменяла жизнь Опры Уинфри",
date: "04.09.2022",
image:
"https://adebiportal.kz/storage/tmp/resize/news/1200_0_0f7ac697e1d82f1749a9d7eba78bd14d.jpeg",
text: "Осень в этом году подкралась незаметно и стала для многих сюрпризом. Вот советы чтобы адаптироваться.",
},
];
const MENU = [
{ id: "m1", name: "Чизбургер", price: 2.5, image: "https://www.foodandwine.com/thmb/DI29Houjc_ccAtFKly0BbVsusHc=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/crispy-comte-cheesburgers-FT-RECIPE0921-6166c6552b7148e8a8561f7765ddf20b.jpg" },
{ id: "m2", name: "Картофель фри", price: 1.5, image: "https://www.inspiredtaste.net/wp-content/uploads/2022/10/Baked-French-Fries-Recipe-1200.jpg" },
{ id: "m3", name: "Кофе", price: 1.2, image: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTH-ZIobq8RxBro_CYrwSP90Ym7NGwcOOmHTg&s" },
{ id: "m4", name: "Куриный бургер", price: 3.1, image: "https://cdn.кухня.рф/preview/16189090-4294-44e4-942b-3e2bda7e1101.webp" },
];
/* ----------------------------- Простая i18n (RU/KZ) ----------------------------- */
const LOCALES = {
ru: {
journal: "Журнал",
menu: "Меню",
cart: "Корзина",
profile: "Профиль",
login: "Войти",
logout: "Выйти",
favorites: "В избранное",
remove: "Убрать",
read: "Читать",
subscribe: "Подписка",
settings: "Настройки",
theme: "Тема",
theme_light: "Светлая",
theme_dark: "Темная",
font_size: "Размер шрифта",
font_small: "Маленький",
font_medium: "Средний",
font_large: "Большой",
language: "Язык",
lang_ru: "Русский",
lang_kz: "Қазақша",
checkout: "Оформить заказ",
empty_cart: "Корзина пуста",
added_cart: "добавлен в корзину",
hello: "Привет",
enter_name: "Введите имя",
save: "Сохранить",
cancel: "Отмена",
add: "Добавить",
},
kz: {
journal: "Журнал",
menu: "Мenyu",
cart: "Себет",
profile: "Профиль",
login: "Кiру",
logout: "Шығу",
favorites: "Таңдаулылар",
remove: "Алу",
read: "Оқу",
subscribe: "Жазылу",
settings: "Баптаулар",
theme: "Тақырып",
theme_light: "Жарық",
theme_dark: "Қараңғы",
font_size: "Шрифт өлшемі",
font_small: "Кіші",
font_medium: "Орташа",
font_large: "Үлкен",
language: "Тіл",
lang_ru: "Русский",
lang_kz: "Қазақша",
checkout: "Тапсырыс жасау",
empty_cart: "Себет бос",
added_cart: "себетке қосылды",
hello: "Сәлем",
enter_name: "Атын енгізіңіз",
save: "Сақтау",
cancel: "Болдырмау",
add: "Қосу",
},
};
function tr(locale, key) {
const dict = LOCALES[locale] || LOCALES.ru;
return dict[key] || key;
}
/* ----------------------------- AsyncStorage helpers ----------------------------- */
async function saveJSON(key, value) {
try {
await AsyncStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.log("AsyncStorage save error", e);
}
}
async function loadJSON(key, fallback = null) {
try {
const s = await AsyncStorage.getItem(key);
if (s === null) return fallback;
return JSON.parse(s);
} catch (e) {
console.log("AsyncStorage load error", e);
return fallback;
}
}
/* ----------------------------- Styles factory (dynamic by theme + fontScale) ----------------------------- */
function makeStyles(themeName = "dark", fontScale = 1) {
const isDark = themeName === "dark";
const palette = {
background: isDark ? "#0B0B0C" : "#F7F7F8",
surface: isDark ? "#101011" : "#ffffff",
text: isDark ? "#fff" : "#111",
subtext: isDark ? "#9a9a9a" : "#666",
accent: "#FFD37A",
border: isDark ? "#222" : "#ddd",
};
const fs = (n) => Math.round(n * fontScale);
const sheet = StyleSheet.create({
safe: { flex: 1, backgroundColor: palette.background },
container: { paddingVertical: 18, paddingHorizontal: 14, backgroundColor: palette.background },
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
},
logo: { color: palette.text, fontSize: fs(20), fontWeight: "bold" },
headerBtn: {
marginLeft: 8,
borderWidth: 1,
borderColor: palette.text,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 18,
},
headerBtnText: { color: palette.text, fontSize: fs(12) },
hero: {
width: "100%",
height: Math.round(width * 0.55),
marginBottom: 12,
borderRadius: 12,
overflow: "hidden",
justifyContent: "flex-end",
},
heroOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: isDark ? "rgba(0,0,0,0.25)" : "rgba(255,255,255,0.15)",
},
heroTextWrap: { padding: 16 },
heroTag: { color: palette.accent, fontWeight: "700", marginBottom: 6, fontSize: fs(12) },
heroTitle: { color: palette.text, fontSize: fs(20), fontWeight: "800", marginBottom: 6 },
heroDesc: { color: palette.subtext, fontSize: fs(13) },
readMore: {
alignSelf: "flex-start",
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 20,
backgroundColor: palette.accent,
marginTop: 8,
},
readMoreText: { fontWeight: "700", fontSize: fs(12) },
sectionTitleWrap: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginVertical: 8,
},
sectionTitle: { color: palette.text, fontSize: fs(18), fontWeight: "700" },
grid: { flexDirection: "row", flexWrap: "wrap", justifyContent: "space-between" },
card: {
width: "48%",
marginBottom: 14,
backgroundColor: palette.surface,
borderRadius: 10,
overflow: "hidden",
},
cardImage: { width: "100%", height: 110 },
cardBody: { padding: 10 },
cardTag: { color: palette.accent, fontSize: fs(11), marginBottom: 6, fontWeight: "700" },
cardTitle: { color: palette.text, fontSize: fs(14), fontWeight: "700", marginBottom: 6 },
cardMeta: { color: palette.subtext, fontSize: fs(11) },
smallBtn: {
alignSelf: "flex-start",
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 18,
borderWidth: 1,
borderColor: palette.text,
},
smallBtnText: { color: palette.text, fontSize: fs(12) },
footer: { marginTop: 24, alignItems: "center" },
footerText: { color: palette.subtext, fontSize: fs(12) },
footerSub: { color: palette.subtext, fontSize: fs(11) },
menuItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 10,
backgroundColor: palette.surface,
borderRadius: 8,
marginBottom: 10,
padding: 8,
},
menuImage: { width: 70, height: 60, borderRadius: 8, backgroundColor: "#ccc" },
addBtn: {
backgroundColor: palette.accent,
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 10,
},
cartItem: { padding: 10, backgroundColor: palette.surface, borderRadius: 8, marginBottom: 8 },
qtyBtn: {
width: 28,
height: 28,
borderRadius: 6,
backgroundColor: palette.text,
alignItems: "center",
justifyContent: "center",
},
cartSummary: { color: palette.text, marginTop: 6 },
checkoutBtn: {
marginTop: 10,
backgroundColor: palette.accent,
padding: 12,
borderRadius: 10,
alignItems: "center",
},
input: {
borderWidth: 1,
borderColor: palette.border,
padding: 10,
borderRadius: 8,
color: palette.text,
marginTop: 6,
},
settingsRow: {
padding: 12,
backgroundColor: palette.surface,
borderRadius: 10,
marginBottom: 10,
},
toggleBtn: {
padding: 10,
borderRadius: 8,
borderWidth: 1,
borderColor: palette.border,
marginTop: 8,
flex: 1,
alignItems: "center",
marginHorizontal: 4,
},
optionRow: {
flexDirection: "row",
justifyContent: "space-between",
marginTop: 8,
alignItems: "center",
},
textPrimary: { color: palette.text, fontSize: fs(14) },
textSecondary: { color: palette.subtext, fontSize: fs(12) },
articleTitle: {
color: palette.text,
fontSize: fs(20),
fontWeight: "800",
marginBottom: 8,
},
articleText: { color: palette.subtext, lineHeight: fs(20) },
});
// Дополнительные «сырые» значения цветов (не стили, а просто строки/объекты)
sheet.accentColor = palette.accent;
sheet.textColor = palette.text;
sheet.subtextColor = palette.subtext;
sheet.borderColor = palette.border;
// цвет текста поверх кнопок +/-
sheet.surfaceText = isDark ? palette.background : "#ffffff";
return sheet;
}
/* ----------------------------- Presentational components ----------------------------- */
function HeaderBar({ onNavigate, user, cartCount, styles, locale }) {
return (
<View style={styles.header}>
<Text style={styles.logo}>BrightMag</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<TouchableOpacity onPress={() => onNavigate("magazine")} style={styles.headerBtn}>
<Text style={styles.headerBtnText}>{tr(locale, "journal")}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => onNavigate("menu")} style={styles.headerBtn}>
<Text style={styles.headerBtnText}>{tr(locale, "menu")}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => onNavigate("cart")} style={styles.headerBtn}>
<Text style={styles.headerBtnText}>
{tr(locale, "cart")} ({cartCount})
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => onNavigate("profile")} style={styles.headerBtn}>
<Text style={styles.headerBtnText}>{user ? user.name : tr(locale, "login")}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => onNavigate("settings")}
style={[styles.headerBtn, { marginLeft: 8 }]}
>
<Text style={styles.headerBtnText}>{tr(locale, "settings")}</Text>
</TouchableOpacity>
</View>
</View>
);
}
function HeroCard({ title, desc, image, onPress, styles }) {
return (
<ImageBackground source={{ uri: image }} style={styles.hero} imageStyle={{ borderRadius: 12 }}>
<View style={styles.heroOverlay} />
<View style={styles.heroTextWrap}>
<Text style={styles.heroTag}>Lifestyle</Text>
<Text style={styles.heroTitle}>{title}</Text>
<Text style={styles.heroDesc} numberOfLines={2}>
{desc}
</Text>
<TouchableOpacity style={styles.readMore} onPress={onPress}>
<Text style={styles.readMoreText}>Читать</Text>
</TouchableOpacity>
</View>
</ImageBackground>
);
}
function ArticleCard({ item, onOpenArticle, onToggleFav, isFav, styles, locale }) {
return (
<View style={styles.card}>
<Image source={{ uri: item.image }} style={styles.cardImage} />
<View style={styles.cardBody}>
<Text style={styles.cardTag}>{item.date}</Text>
<Text numberOfLines={2} style={styles.cardTitle}>
{item.title}
</Text>
<Text style={styles.cardMeta}>{(item.text || "").slice(0, 80)}...</Text>
<View style={{ flexDirection: "row", marginTop: 8 }}>
<TouchableOpacity onPress={() => onOpenArticle(item)} style={styles.smallBtn}>
<Text style={styles.smallBtnText}>{tr(locale, "read")}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => onToggleFav(item)}
style={[
styles.smallBtn,
{
marginLeft: 8,
borderColor: isFav ? styles.accentColor : styles.textColor,
},
]}
>
<Text style={styles.smallBtnText}>
{isFav ? tr(locale, "remove") : tr(locale, "favorites")}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
}
function MenuItem({ item, onAdd, styles, locale }) {
const handleAdd = () => {
if (typeof onAdd === "function") {
onAdd(item);
}
Alert.alert(tr(locale, "menu"), `${item.name} ${tr(locale, "added_cart")}`);
};
return (
<View style={styles.menuItem}>
<Image source={{ uri: item.image }} style={styles.menuImage} />
<View style={{ flex: 1, paddingLeft: 10 }}>
<Text
style={{
color: styles.textColor,
fontWeight: "700",
}}
>
{item.name}
</Text>
<Text
style={{
color: styles.subtextColor,
}}
>
{item.price.toFixed(2)} $
</Text>
</View>
<TouchableOpacity style={styles.addBtn} onPress={handleAdd}>
<Text style={{ fontWeight: "700" }}>{tr(locale, "add")}</Text>
</TouchableOpacity>
</View>
);
}
/* ----------------------------- Screens (containers) ----------------------------- */
function MagazineScreen({ onOpenArticle, favorites, toggleFav, styles, locale }) {
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
async function fetchArticles() {
setLoading(true);
try {
const res = await fetch("https://632de3dc519d17fb53c86f97.mockapi.io/post");
if (!res.ok) throw new Error("network");
const data = await res.json();
const mapped = (data || []).map((it, idx) => ({
id: String(it.id || idx),
title: it.title || it.name || "Без названия",
date: it.date || it.createdAt || "—",
image:
it.image ||
it.pic ||
FALLBACK_ARTICLES[idx % FALLBACK_ARTICLES.length].image,
text: it.text || it.body || "",
}));
if (mapped.length === 0) throw new Error("empty");
setArticles(mapped);
} catch (e) {
console.log("fetch error, using fallback", e);
setArticles(FALLBACK_ARTICLES);
} finally {
setLoading(false);
}
}
useEffect(() => {
fetchArticles();
}, []);
if (loading) {
return (
<View>
<View style={styles.sectionTitleWrap}>
<Text style={styles.sectionTitle}>{tr(locale, "journal")}</Text>
</View>
<Text style={{ color: styles.subtextColor, padding: 12 }}>Загрузка...</Text>
</View>
);
}
const first = articles[0];
return (
<View>
<View style={styles.sectionTitleWrap}>
<Text style={styles.sectionTitle}>{tr(locale, "journal")}</Text>
</View>
{first && (
<>
<HeroCard
title={first.title}
desc={first.text}
image={first.image}
onPress={() => onOpenArticle(first)}
styles={styles}
/>
<View style={{ height: 8 }} />
</>
)}
<View style={styles.grid}>
{articles.map((a) => (
<ArticleCard
key={a.id}
item={a}
onOpenArticle={onOpenArticle}
onToggleFav={toggleFav}
isFav={favorites.some((f) => f.id === a.id)}
styles={styles}
locale={locale}
/>
))}
</View>
</View>
);
}
function ArticleScreen({ article, onBack, styles, locale }) {
if (!article) return null;
return (
<View>
<TouchableOpacity onPress={onBack} style={{ marginBottom: 8 }}>
<Text style={{ color: styles.accentColor }}>← {tr(locale, "journal")}</Text>
</TouchableOpacity>
<Text style={styles.articleTitle}>{article.title}</Text>
<Image
source={{ uri: article.image }}
style={{ width: "100%", height: 180, borderRadius: 8, marginBottom: 8 }}
/>
<Text style={styles.articleText}>{article.text || "Текст отсутствует"}</Text>
</View>
);
}
function MenuScreen({ onAddToCart, styles, locale }) {
return (
<View>
<View style={styles.sectionTitleWrap}>
<Text style={styles.sectionTitle}>{tr(locale, "menu")}</Text>
</View>
<View>
{MENU.map((m) => (
<MenuItem key={m.id} item={m} onAdd={onAddToCart} styles={styles} locale={locale} />
))}
</View>
</View>
);
}
function CartScreen({ cart, updateQty, removeItem, onCheckout, styles, locale }) {
const subtotal = cart.reduce((s, it) => s + it.price * it.qty, 0);
const tax = subtotal * TAX_RATE;
const total = subtotal + tax;
return (
<View>
<View style={styles.sectionTitleWrap}>
<Text style={styles.sectionTitle}>{tr(locale, "cart")}</Text>
</View>
{cart.length === 0 ? (
<Text style={{ color: styles.subtextColor, padding: 12 }}>
{tr(locale, "empty_cart")}
</Text>
) : (
<View>
{cart.map((it) => (
<View key={it.id} style={styles.cartItem}>
<Text
style={{
color: styles.textColor,
fontWeight: "700",
}}
>
{it.name}
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
marginTop: 8,
}}
>
<TouchableOpacity
onPress={() => updateQty(it.id, Math.max(1, it.qty - 1))}
style={styles.qtyBtn}
>
<Text style={{ color: styles.surfaceText }}>-</Text>
</TouchableOpacity>
<Text
style={{
color: styles.textColor,
marginHorizontal: 8,
}}
>
{it.qty}
</Text>
<TouchableOpacity
onPress={() => updateQty(it.id, it.qty + 1)}
style={styles.qtyBtn}
>
<Text style={{ color: styles.surfaceText }}>+</Text>
</TouchableOpacity>
<Text
style={{
color: styles.subtextColor,
marginLeft: 12,
}}
>
{(it.price * it.qty).toFixed(2)} </Text>
<TouchableOpacity
onPress={() => removeItem(it.id)}
style={[styles.smallBtn, { marginLeft: 12 }]}
>
<Text style={styles.smallBtnText}>{tr(locale, "remove")}</Text>
</TouchableOpacity>
</View>
</View>
))}
<View style={{ marginTop: 12 }}>
<Text style={styles.cartSummary}>Промежуточно: {subtotal.toFixed(2)}</Text>
<Text style={styles.cartSummary}>
Налог ({(TAX_RATE * 100).toFixed(0)}%): {tax.toFixed(2)}
</Text>
<TouchableOpacity style={styles.checkoutBtn} onPress={() => onCheckout(total)}>
<Text style={{ fontWeight: "700" }}>{tr(locale, "checkout")}</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
);
}
function ProfileScreen({
user,
onLogin,
onLogout,
favorites,
onOpenArticle,
toggleFav,
styles,
locale,
}) {
const [name, setName] = useState(user ? user.name : "");
useEffect(() => {
setName(user ? user.name : "");
}, [user]);
return (
<View>
<View style={styles.sectionTitleWrap}>
<Text style={styles.sectionTitle}>{tr(locale, "profile")}</Text>
</View>
text{user ? ( <> <Text style={styles.textPrimary}> {tr(locale, "hello")}, {user.name} 👋 </Text> <TouchableOpacity style={[styles.smallBtn, { marginTop: 10 }]} onPress={onLogout}> <Text style={styles.smallBtnText}>{tr(locale, "logout")}</Text> </TouchableOpacity> <View style={{ marginTop: 20 }}> <Text style={styles.sectionTitle}>{tr(locale, "favorites")}</Text> {favorites.length === 0 ? ( <Text style={{ color: styles.subtextColor, marginTop: 6, }} > — </Text> ) : ( <View style={styles.grid}> {favorites.map((a) => ( <ArticleCard key={a.id} item={a} onOpenArticle={onOpenArticle} onToggleFav={toggleFav} isFav={true} styles={styles} locale={locale} /> ))} </View> )} </View> </> ) : ( <> <Text style={styles.textPrimary}>{tr(locale, "enter_name")}</Text> <TextInput style={styles.input} value={name} onChangeText={setName} placeholder={tr(locale, "enter_name")} placeholderTextColor={styles.subtextColor} /> <TouchableOpacity style={[styles.smallBtn, { marginTop: 10 }]} onPress={() => { const trimmed = name.trim(); if (!trimmed) { Alert.alert("Ошибка", "Введите имя"); return; } onLogin(trimmed); }} > <Text style={styles.smallBtnText}>{tr(locale, "login")}</Text> </TouchableOpacity> </> )} </View>
);
}
function SettingsScreen({ settings, onChangeSettings, styles, locale }) {
const { theme, fontScale, locale: appLocale } = settings;
const setTheme = (t) => onChangeSettings({ theme: t });
const setLocale = (l) => onChangeSettings({ locale: l });
const setFontScale = (f) => onChangeSettings({ fontScale: f });
return (
<View>
<View style={styles.sectionTitleWrap}>
<Text style={styles.sectionTitle}>{tr(locale, "settings")}</Text>
</View>
text{/* Theme */} <View style={styles.settingsRow}> <Text style={styles.textPrimary}>{tr(locale, "theme")}</Text> <View style={styles.optionRow}> <TouchableOpacity style={[ styles.toggleBtn, { borderColor: theme === "light" ? styles.accentColor : styles.borderColor, }, ]} onPress={() => setTheme("light")} > <Text style={styles.textSecondary}> {tr(locale, "theme_light")} {theme === "light" ? "✓" : ""} </Text> </TouchableOpacity> <TouchableOpacity style={[ styles.toggleBtn, { borderColor: theme === "dark" ? styles.accentColor : styles.borderColor, }, ]} onPress={() => setTheme("dark")} > <Text style={styles.textSecondary}> {tr(locale, "theme_dark")} {theme === "dark" ? "✓" : ""} </Text> </TouchableOpacity> </View> </View> {/* Font size */} <View style={styles.settingsRow}> <Text style={styles.textPrimary}>{tr(locale, "font_size")}</Text> <View style={styles.optionRow}> <TouchableOpacity style={[ styles.toggleBtn, { borderColor: fontScale === 0.9 ? styles.accentColor : styles.borderColor, }, ]} onPress={() => setFontScale(0.9)} > <Text style={styles.textSecondary}> {tr(locale, "font_small")} {fontScale === 0.9 ? "✓" : ""} </Text> </TouchableOpacity> <TouchableOpacity style={[ styles.toggleBtn, { borderColor: fontScale === 1 ? styles.accentColor : styles.borderColor, }, ]} onPress={() => setFontScale(1)} > <Text style={styles.textSecondary}> {tr(locale, "font_medium")} {fontScale === 1 ? "✓" : ""} </Text> </TouchableOpacity> <TouchableOpacity style={[ styles.toggleBtn, { borderColor: fontScale === 1.2 ? styles.accentColor : styles.borderColor, }, ]} onPress={() => setFontScale(1.2)} > <Text style={styles.textSecondary}> {tr(locale, "font_large")} {fontScale === 1.2 ? "✓" : ""} </Text> </TouchableOpacity> </View> </View> {/* Language */} <View style={styles.settingsRow}> <Text style={styles.textPrimary}>{tr(locale, "language")}</Text> <View style={styles.optionRow}> <TouchableOpacity style={[ styles.toggleBtn, { borderColor: appLocale === "ru" ? styles.accentColor : styles.borderColor, }, ]} onPress={() => setLocale("ru")} > <Text style={styles.textSecondary}> {tr(locale, "lang_ru")} {appLocale === "ru" ? "✓" : ""} </Text> </TouchableOpacity> <TouchableOpacity style={[ styles.toggleBtn, { borderColor: appLocale === "kz" ? styles.accentColor : styles.borderColor, }, ]} onPress={() => setLocale("kz")} > <Text style={styles.textSecondary}> {tr(locale, "lang_kz")} {appLocale === "kz" ? "✓" : ""} </Text> </TouchableOpacity> </View> </View> </View>
);
}
/* ----------------------------- Root App ----------------------------- */
export default function App() {
const [settings, setSettings] = useState({
theme: "dark",
fontScale: 1,
locale: "ru",
});
const [user, setUser] = useState(null); // { name }
const [favorites, setFavorites] = useState([]);
const [cart, setCart] = useState([]);
const [screen, setScreen] = useState("magazine");
const [prevScreen, setPrevScreen] = useState("magazine");
const [selectedArticle, setSelectedArticle] = useState(null);
const [ready, setReady] = useState(false);
const locale = settings.locale || "ru";
const styles = useMemo(
() => makeStyles(settings.theme, settings.fontScale),
[settings.theme, settings.fontScale],
);
// Initial load (AsyncStorage)
useEffect(() => {
(async () => {
try {
const [storedSettings, storedFavs, storedCart, token] = await Promise.all([
loadJSON(STORAGE_KEYS.SETTINGS, null),
loadJSON(STORAGE_KEYS.FAVORITES, []),
loadJSON(STORAGE_KEYS.CART, []),
AsyncStorage.getItem(STORAGE_KEYS.TOKEN),
]);
textconst mergedSettings = { theme: "dark", fontScale: 1, locale: "ru", ...(storedSettings || {}), }; setSettings(mergedSettings); setFavorites(storedFavs || []); setCart( (storedCart || []).map((it) => ({ ...it, qty: it.qty || 1, })), ); if (token && mergedSettings.userName) { setUser({ name: mergedSettings.userName }); } } catch (e) { console.log("Init load error", e); } finally { setReady(true); } })();
}, []);
// Persist settings / favorites / cart
useEffect(() => {
if (!ready) return;
saveJSON(STORAGE_KEYS.SETTINGS, settings);
}, [settings, ready]);
useEffect(() => {
if (!ready) return;
saveJSON(STORAGE_KEYS.FAVORITES, favorites);
}, [favorites, ready]);
useEffect(() => {
if (!ready) return;
saveJSON(STORAGE_KEYS.CART, cart);
}, [cart, ready]);
// Navigation
const navigate = (name) => {
setScreen(name);
if (name !== "article") {
setSelectedArticle(null);
}
};
const openArticle = (article) => {
setPrevScreen(screen);
setSelectedArticle(article);
setScreen("article");
};
const goBackFromArticle = () => {
setScreen(prevScreen);
setSelectedArticle(null);
};
// Favorites
const toggleFav = (article) => {
setFavorites((prev) => {
const exists = prev.some((f) => f.id === article.id);
if (exists) {
return prev.filter((f) => f.id !== article.id);
}
return [...prev, article];
});
};
// Cart
const addToCart = (item) => {
setCart((prev) => {
const existing = prev.find((it) => it.id === item.id);
if (existing) {
return prev.map((it) =>
it.id === item.id ? { ...it, qty: it.qty + 1 } : it,
);
}
return [...prev, { ...item, qty: 1 }];
});
};
const updateQty = (id, qty) => {
setCart((prev) =>
prev.map((it) =>
it.id === id ? { ...it, qty: Math.max(1, qty) } : it,
),
);
};
const removeItem = (id) => {
setCart((prev) => prev.filter((it) => it.id !== id));
};
const checkout = (total) => {
Alert.alert(tr(locale, "checkout"), Спасибо за заказ! Сумма: ${total.toFixed(2)} $);
setCart([]);
};
// Settings change
const handleSettingsChange = (patch) => {
setSettings((prev) => ({ ...prev, ...patch }));
};
// Auth
const login = async (name) => {
setUser({ name });
setSettings((prev) => ({ ...prev, userName: name }));
try {
await AsyncStorage.setItem(STORAGE_KEYS.TOKEN, "demo-token");
} catch (e) {
console.log("token save error", e);
}
};
const logout = async () => {
setUser(null);
setSettings((prev) => ({ ...prev, userName: undefined }));
try {
await AsyncStorage.removeItem(STORAGE_KEYS.TOKEN);
} catch (e) {
console.log("token remove error", e);
}
};
const cartCount = cart.reduce((s, it) => s + it.qty, 0);
let content = null;
if (!ready) {
content = (
<Text style={{ color: styles.subtextColor, padding: 16 }}>
Загрузка приложения...
</Text>
);
} else {
switch (screen) {
case "magazine":
content = (
<MagazineScreen
onOpenArticle={openArticle}
favorites={favorites}
toggleFav={toggleFav}
styles={styles}
locale={locale}
/>
);
break;
case "menu":
content = <MenuScreen onAddToCart={addToCart} styles={styles} locale={locale} />;
break;
case "cart":
content = (
<CartScreen
cart={cart}
updateQty={updateQty}
removeItem={removeItem}
onCheckout={checkout}
styles={styles}
locale={locale}
/>
);
break;
case "profile":
content = (
<ProfileScreen
user={user}
onLogin={login}
onLogout={logout}
favorites={favorites}
onOpenArticle={openArticle}
toggleFav={toggleFav}
styles={styles}
locale={locale}
/>
);
break;
case "settings":
content = (
<SettingsScreen
settings={settings}
onChangeSettings={handleSettingsChange}
styles={styles}
locale={locale}
/>
);
break;
case "article":
content = (
<ArticleScreen
article={selectedArticle}
onBack={goBackFromArticle}
styles={styles}
locale={locale}
/>
);
break;
default:
content = null;
}
}
return (
<SafeAreaView style={styles.safe}>
<StatusBar barStyle={settings.theme === "dark" ? "light-content" : "dark-content"} />
<ScrollView style={styles.container}>
<HeaderBar
onNavigate={navigate}
user={user}
cartCount={cartCount}
styles={styles}
locale={locale}
/>
{content}
<View style={styles.footer}>
<Text style={styles.footerText}>BrightMag © 2025</Text>
<Text style={styles.footerSub}>
Demo labs 16–20 (AsyncStorage, fetch, i18n, settings, корзина)
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}В разделе журнал исправь и измени старые на новые информации, добавь еще креативностей чтобы после нажатия кнопки читать отображалась информация, блоки должны быть максимум 5-6, (просто в моем коде внизу блоки повторяются) изображения каждого блока не должны быть похожими а разными(и чтобы без ошибок отображались).
Сделаю так, чтобы:
Ниже только те куски кода, которые нужно заменить.
FALLBACK_ARTICLES (новые тексты + разные картинки)Замени у себя константу FALLBACK_ARTICLES вот на эту:
jsconst FALLBACK_ARTICLES = [ { id: "1", title: "5 утренних ритуалов для яркого дня", date: "04.02.2025", image: "https://images.pexels.com/photos/4050416/pexels-photo-4050416.jpeg?auto=compress&cs=tinysrgb&w=1200", text: "Начни день без спешки: стакан воды, лёгкая зарядка, три цели на день и один маленький подарок себе — любимый плейлист или кофе. Эти простые ритуалы помогают просыпаться с хорошим настроением и не выпадать из ресурса.", }, { id: "2", title: "Digital detox: как отдохнуть от телефона за выходные", date: "18.01.2025", image: "https://images.pexels.com/photos/214574/pexels-photo-214574.jpeg?auto=compress&cs=tinysrgb&w=1200", text: "Попробуй два дня без бесконечной ленты: уведомления — в режим «Не беспокоить», телефон — подальше от кровати, вместо соцсетей — прогулка, бумажная книга и живое общение. Мозг удивится, как много энергии возвращается.", }, { id: "3", title: "Тёплый вечер для себя: мини-ритуал осознанного отдыха", date: "02.12.2024", image: "https://images.pexels.com/photos/892649/pexels-photo-892649.jpeg?auto=compress&cs=tinysrgb&w=1200", text: "Выключи лишний свет, зажги свечу, приготовь тёплый напиток и на 30 минут отключись от дел. Можно писать дневник, рисовать или просто лежать в тишине. Главное правило: никаких «надо», только «хочу».", }, { id: "4", title: "Как не выгореть, работая из дома", date: "10.11.2024", image: "https://images.pexels.com/photos/4145190/pexels-photo-4145190.jpeg?auto=compress&cs=tinysrgb&w=1200", text: "Сделай рабочее место красивым: плед, растение, любимая кружка. Разделяй время: работа — по таймеру, отдых — без чувства вины. И не забывай выходить на улицу хотя бы на 15 минут днём, чтобы перезагрузить голову.", }, { id: "5", title: "Небольшие привычки для здоровой энергии", date: "27.10.2024", image: "https://images.pexels.com/photos/1640777/pexels-photo-1640777.jpeg?auto=compress&cs=tinysrgb&w=1200", text: "Стакан воды после пробуждения, короткая прогулка после еды, один приём пищи без гаджетов и ранний уход в кровать хотя бы пару раз в неделю. Маленькие шаги незаметно собираются в большое изменение самочувствия.", }, { id: "6", title: "Маленькое путешествие по своему городу", date: "05.09.2024", image: "https://images.pexels.com/photos/415829/pexels-photo-415829.jpeg?auto=compress&cs=tinysrgb&w=1200", text: "Выбери один район, где ты почти не бываешь, и устрой себе мини-путешествие: новый кофе-бар, необычный двор, локальный магазин. Необязательно уезжать далеко, чтобы почувствовать свежесть и вдохновение.", }, ];
✅ Здесь:
MagazineScreen, чтобы:Замени весь компонент MagazineScreen на этот:
jsxfunction MagazineScreen({ onOpenArticle, favorites, toggleFav, styles, locale }) { const [articles, setArticles] = useState([]); const [loading, setLoading] = useState(true); async function fetchArticles() { setLoading(true); try { const res = await fetch("https://632de3dc519d17fb53c86f97.mockapi.io/post"); if (!res.ok) throw new Error("network"); const data = await res.json(); const mapped = (data || []).map((it, idx) => ({ id: String(it.id || idx), title: it.title || it.name || "Без названия", date: it.date || it.createdAt || "—", image: it.image || it.pic || FALLBACK_ARTICLES[idx % FALLBACK_ARTICLES.length].image, text: it.text || it.body || "", })); // Берём максимум 6 статей const limited = mapped.length > 0 ? mapped.slice(0, 6) : FALLBACK_ARTICLES; setArticles(limited); } catch (e) { console.log("fetch error, using fallback", e); // Если сервер не ответил — показываем наши новые креативные статьи setArticles(FALLBACK_ARTICLES.slice(0, 6)); } finally { setLoading(false); } } useEffect(() => { fetchArticles(); }, []); if (loading) { return ( <View> <View style={styles.sectionTitleWrap}> <Text style={styles.sectionTitle}>{tr(locale, "journal")}</Text> </View> <Text style={{ color: styles.subtextColor, padding: 12 }}>Загрузка...</Text> </View> ); } const first = articles[0]; const rest = articles.slice(1, 6); // остальные 4–5 карточек, без первого return ( <View> <View style={styles.sectionTitleWrap}> <Text style={styles.sectionTitle}>{tr(locale, "journal")}</Text> </View> {first && ( <> <HeroCard title={first.title} desc={first.text} image={first.image} onPress={() => onOpenArticle(first)} styles={styles} /> <View style={{ height: 8 }} /> </> )} <View style={styles.grid}> {rest.map((a) => ( <ArticleCard key={a.id} item={a} onOpenArticle={onOpenArticle} onToggleFav={toggleFav} isFav={favorites.some((f) => f.id === a.id)} styles={styles} locale={locale} /> ))} </View> </View> ); }
Теперь:
articles.slice(1, 6), то есть без дубликата;Чтобы после «Читать» отображалось чуть красивее (с датой), можно слегка обновить ArticleScreen.
Замени ArticleScreen на:
jsxfunction ArticleScreen({ article, onBack, styles, locale }) { if (!article) return null; return ( <View> <TouchableOpacity onPress={onBack} style={{ marginBottom: 8 }}> <Text style={{ color: styles.accentColor }}>← {tr(locale, "journal")}</Text> </TouchableOpacity> <Text style={styles.articleTitle}>{article.title}</Text> {article.date ? ( <Text style={[styles.textSecondary, { marginBottom: 8 }]}> {article.date} </Text> ) : null} <Image source={{ uri: article.image }} style={{ width: "100%", height: 180, borderRadius: 8, marginBottom: 8 }} /> <Text style={[styles.articleText, { marginTop: 4 }]}> {article.text || "Текст отсутствует"} </Text> </View> ); }
После этих трёх правок:
Если хочешь, дальше можем добавить, например, бейдж «New» для свежих статей или фильтр по настроению ✨.
Лабораторная-19.Умные и глупые компоненты
Цель работы
В этой работе мы познакомимся со способом структурирования логики
приложения с помощью «умных» и «глупых» компонент.
Задания для выполнения
Ознакомьтесь с презентацией: http://mathclub23.ru/wpcontent/uploads/2022/11/Умные-и-глупые-компоненты-React.pptx
Используйте пример книги контактов из Лабораторной 12 сделайте:
ВНИМАНИЕ: идентичные работы засчитываться не будут.
6. Поделитесь ссылкой на проект в Expo. Загрузить созданное
приложение на GitHub в репозиторий Student, используя формат в названии
Фамилия (латинскими буквами)_19.
Полезные ссылки
https://youtu.be/zeS3MrlF5dw
https://youtu.be/S2hQ_Tu39jo?list=PLth6QPteH5guFUwQj1iCdu-SOZ7O5KCz4
теперь отдельно выполни это задание чтобы было 100% без ошибок
и креативно сделай , ярким цветотипом
Ниже — готовая ЛР-19 с умными и глупыми компонентами на тему книжки контактов, в ярком цвете 🌈
ContactsScreen (хранит состояние, фильтрует, ищет, вызывает UI-компоненты);AppHeader, SearchInput, FilterPills, ContactCard, ContactList;ContactCard;App.js, можно сразу вставлять в Expo Snack — без дополнительных библиотек.App.jsПросто скопируй это в свой проект (App.js в Expo/Expo Snack):
jsximport React, { useMemo, useState } from "react"; import { SafeAreaView, View, Text, TextInput, FlatList, TouchableOpacity, StyleSheet, StatusBar, } from "react-native"; /* ----------------------------- Мок-данные контактов ----------------------------- */ const INITIAL_CONTACTS = [ { id: "1", name: "Aruzhan Bright", phone: "+7 777 111 11 11", email: "[email protected]", tag: "friends", favorite: true, color: "#FF6B6B", }, { id: "2", name: "Dias Tech", phone: "+7 702 222 22 22", email: "[email protected]", tag: "work", favorite: true, color: "#FFD93D", }, { id: "3", name: "Mira Art", phone: "+7 705 333 33 33", email: "[email protected]", tag: "family", favorite: false, color: "#6C5CE7", }, { id: "4", name: "Rustem Coach", phone: "+7 701 444 44 44", email: "[email protected]", tag: "work", favorite: false, color: "#00B894", }, { id: "5", name: "Alina Music", phone: "+7 747 555 55 55", email: "[email protected]", tag: "friends", favorite: false, color: "#E84393", }, { id: "6", name: "Grandma Zere", phone: "+7 708 666 66 66", email: "[email protected]", tag: "family", favorite: true, color: "#0984E3", }, ]; /* ----------------------------- Цветовая палитра (яркая) ----------------------------- */ const palette = { background: "#08041A", card: "#14122B", cardAlt: "#181633", textPrimary: "#FFFFFF", textSecondary: "#C6C6E8", accentPink: "#FF6BDF", accentYellow: "#FFD93D", accentMint: "#2ECC71", chipBg: "#241C3F", inputBg: "#100E25", borderSoft: "#332F52", }; /* ----------------------------- ГЛУПЫЕ КОМПОНЕНТЫ (UI) ----------------------------- */ /** Глупый компонент: только отображает заголовок, не хранит state */ function AppHeader({ title, subtitle }) { return ( <View style={styles.headerContainer}> <View> <Text style={styles.appTitle}>{title}</Text> <Text style={styles.appSubtitle}>{subtitle}</Text> </View> <View style={styles.headerBadge}> <Text style={styles.headerBadgeText}>Lab 19</Text> </View> </View> ); } /** Глупый компонент: поле поиска */ function SearchInput({ value, onChangeText }) { return ( <View style={styles.searchContainer}> <Text style={styles.searchLabel}>Поиск контактов</Text> <TextInput value={value} onChangeText={onChangeText} placeholder="Имя, телефон или email" placeholderTextColor="#7777A5" style={styles.searchInput} /> </View> ); } /** Глупый компонент: фильтры-«таблетки» */ function FilterPills({ activeFilter, onChangeFilter }) { const filters = [ { id: "all", label: "Все" }, { id: "favorites", label: "Избранные" }, { id: "friends", label: "Друзья" }, { id: "family", label: "Семья" }, { id: "work", label: "Работа" }, ]; return ( <View style={styles.filtersRow}> {filters.map((f) => { const isActive = activeFilter === f.id; return ( <TouchableOpacity key={f.id} style={[ styles.filterPill, isActive && styles.filterPillActive, // немного transform для живости isActive && { transform: [{ translateY: -2 }, { scale: 1.02 }] }, ]} onPress={() => onChangeFilter(f.id)} > <Text style={[ styles.filterPillText, isActive && styles.filterPillTextActive, ]} > {f.label} </Text> </TouchableOpacity> ); })} </View> ); } /** Глупый компонент: одиночная карточка контакта */ function ContactCard({ contact, onToggleFavorite }) { const initials = contact.name .split(" ") .map((w) => w[0]) .join("") .slice(0, 2) .toUpperCase(); return ( <View style={styles.cardOuter}> <View style={[ styles.card, { backgroundColor: contact.favorite ? palette.cardAlt : palette.card, transform: contact.favorite ? [{ translateY: -2 }, { rotateZ: "-1deg" }] : [{ translateY: 0 }, { rotateZ: "0deg" }], }, ]} > <View style={[ styles.avatarCircle, { backgroundColor: contact.color, transform: [{ scale: contact.favorite ? 1.08 : 1 }], }, ]} > <Text style={styles.avatarText}>{initials}</Text> </View> <View style={styles.cardContent}> <Text style={styles.contactName}>{contact.name}</Text> <Text style={styles.contactMeta}>{contact.phone}</Text> <Text style={styles.contactMeta}>{contact.email}</Text> <View style={styles.tagsRow}> <View style={styles.tagBadge}> <Text style={styles.tagText}>{contact.tag}</Text> </View> <TouchableOpacity onPress={() => onToggleFavorite(contact.id)} style={[ styles.favoriteBtn, contact.favorite && styles.favoriteBtnActive, ]} > <Text style={[ styles.favoriteBtnText, contact.favorite && styles.favoriteBtnTextActive, ]} > {contact.favorite ? "★ Избранный" : "☆ В избранное"} </Text> </TouchableOpacity> </View> </View> </View> </View> ); } /** Глупый компонент: просто список, который вызывает ContactCard */ function ContactList({ contacts, onToggleFavorite }) { if (contacts.length === 0) { return ( <View style={styles.emptyStateBox}> <Text style={styles.emptyStateTitle}>Ничего не найдено</Text> <Text style={styles.emptyStateText}> Попробуйте изменить текст поиска или фильтр. </Text> </View> ); } return ( <FlatList data={contacts} keyExtractor={(item) => item.id} contentContainerStyle={{ paddingBottom: 32 }} renderItem={({ item }) => ( <ContactCard contact={item} onToggleFavorite={onToggleFavorite} /> )} /> ); } /* ----------------------------- УМНЫЙ КОМПОНЕНТ (контейнер) ----------------------------- */ function ContactsScreen() { // умный компонент хранит состояние и бизнес-логику const [contacts, setContacts] = useState(INITIAL_CONTACTS); const [search, setSearch] = useState(""); const [filter, setFilter] = useState("all"); const filteredContacts = useMemo(() => { const query = search.trim().toLowerCase(); return contacts.filter((c) => { const matchesSearch = query.length === 0 || c.name.toLowerCase().includes(query) || (c.phone && c.phone.toLowerCase().includes(query)) || (c.email && c.email.toLowerCase().includes(query)); let matchesFilter = true; if (filter === "favorites") { matchesFilter = c.favorite; } else if (filter === "friends") { matchesFilter = c.tag === "friends"; } else if (filter === "family") { matchesFilter = c.tag === "family"; } else if (filter === "work") { matchesFilter = c.tag === "work"; } return matchesSearch && matchesFilter; }); }, [contacts, search, filter]); const handleToggleFavorite = (id) => { setContacts((prev) => prev.map((c) => c.id === id ? { ...c, favorite: !c.favorite } : c ) ); }; return ( <View style={styles.screen}> <AppHeader title="Smart Contacts" subtitle="Умные и глупые компоненты • React Native" /> <SearchInput value={search} onChangeText={setSearch} /> <FilterPills activeFilter={filter} onChangeFilter={setFilter} /> <ContactList contacts={filteredContacts} onToggleFavorite={handleToggleFavorite} /> </View> ); } /* ----------------------------- Root App ----------------------------- */ export default function App() { return ( <SafeAreaView style={styles.safe}> <StatusBar barStyle="light-content" /> <ContactsScreen /> </SafeAreaView> ); } /* ----------------------------- Стили ----------------------------- */ const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: palette.background, }, screen: { flex: 1, paddingHorizontal: 16, paddingTop: 12, }, /* Header */ headerContainer: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 16, }, appTitle: { color: palette.textPrimary, fontSize: 26, fontWeight: "800", }, appSubtitle: { color: palette.textSecondary, fontSize: 13, marginTop: 4, }, headerBadge: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: palette.accentPink, transform: [{ translateY: -2 }], shadowColor: palette.accentPink, shadowOpacity: 0.5, shadowOffset: { width: 0, height: 0 }, shadowRadius: 10, elevation: 4, }, headerBadgeText: { color: "#12061C", fontWeight: "700", fontSize: 12, }, /* Search */ searchContainer: { marginBottom: 10, }, searchLabel: { color: palette.textSecondary, marginBottom: 6, fontSize: 13, }, searchInput: { backgroundColor: palette.inputBg, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, color: palette.textPrimary, borderWidth: 1, borderColor: palette.borderSoft, fontSize: 14, }, /* Filters */ filtersRow: { flexDirection: "row", flexWrap: "wrap", marginVertical: 10, }, filterPill: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: palette.chipBg, marginRight: 8, marginBottom: 8, borderWidth: 1, borderColor: "transparent", }, filterPillActive: { backgroundColor: "#2D1F4F", borderColor: palette.accentYellow, }, filterPillText: { color: palette.textSecondary, fontSize: 12, }, filterPillTextActive: { color: palette.accentYellow, fontWeight: "700", }, /* Cards */ cardOuter: { marginBottom: 14, }, card: { flexDirection: "row", padding: 14, borderRadius: 18, borderWidth: 1, borderColor: palette.borderSoft, }, avatarCircle: { width: 52, height: 52, borderRadius: 26, alignItems: "center", justifyContent: "center", marginRight: 12, shadowColor: "#FFFFFF", shadowOpacity: 0.2, shadowOffset: { width: 0, height: 0 }, shadowRadius: 8, elevation: 3, }, avatarText: { color: "#fff", fontWeight: "800", fontSize: 18, }, cardContent: { flex: 1, }, contactName: { color: palette.textPrimary, fontSize: 16, fontWeight: "700", }, contactMeta: { color: palette.textSecondary, fontSize: 12, marginTop: 2, }, tagsRow: { flexDirection: "row", alignItems: "center", marginTop: 8, }, tagBadge: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 999, backgroundColor: "rgba(255,255,255,0.06)", marginRight: 8, }, tagText: { color: palette.textSecondary, fontSize: 11, }, favoriteBtn: { paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, borderWidth: 1, borderColor: palette.accentPink, }, favoriteBtnActive: { backgroundColor: palette.accentPink, }, favoriteBtnText: { fontSize: 11, color: palette.accentPink, fontWeight: "600", }, favoriteBtnTextActive: { color: "#12061C", }, /* Empty state */ emptyStateBox: { marginTop: 32, padding: 18, borderRadius: 18, backgroundColor: "#120F2A", borderWidth: 1, borderColor: palette.borderSoft, alignItems: "center", }, emptyStateTitle: { color: palette.textPrimary, fontSize: 16, fontWeight: "700", marginBottom: 6, }, emptyStateText: { color: palette.textSecondary, fontSize: 13, textAlign: "center", }, });
Умный компонент:
ContactsScreen — держит contacts, search, filter, фильтрует данные (useMemo), передаёт пропсы вниз.Глупые компоненты (UI):
AppHeader, SearchInput, FilterPills, ContactCard, ContactList.Каждый контакт рендерится через глупый компонент ContactCard из ContactList.
Transforms:
FilterPills (активный фильтр чуть приподнят и увеличен).ContactCard (избранные контакты чуть наклонены и «выпрыгивают»).Яркий, «живой» интерфейс — не стандартная сине-белая тема.
Если хочешь, могу дополнительно помочь оформить README для GitHub и подсказать, как лучше назвать репозиторий (Familiya_19).
[{
"resource": "/c:/Users/Айшагуль/lab8/tsconfig.json",
"owner": "typescript",
"severity": 8,
"message": "File 'expo/tsconfig.base' not found.",
"source": "ts",
"startLineNumber": 2,
"startColumn": 14,
"endLineNumber": 2,
"endColumn": 34,
"origin": "extHost1"
}]
вот такая ошибка выходит
Это не ошибка твоего кода, это просто TypeScript ругается на неправильный/лишний tsconfig.json 👍
Сообщение:
File 'expo/tsconfig.base' not found.
значит: в tsconfig.json написано "extends": "expo/tsconfig.base", но в проекте нет файла node_modules/expo/tsconfig.base.json (или вообще нет expo / node_modules).
Твои лабораторные — на JavaScript, TypeScript не нужен вообще. Тогда:
tsconfig.jsonc:/Users/Айшагуль/lab8/.tsconfig.json.Ctrl+Shift+P → «TypeScript: Restart TS server»).Expo/React Native спокойно работают без tsconfig.json, если у тебя .js файлы.
expo/tsconfig.baseЕсли по каким-то причинам хочешь оставить tsconfig.json, просто сделай его простым и не завязанным на Expo.
Открой tsconfig.json и замени содержимое на:
json{ "compilerOptions": { "allowJs": true, "checkJs": false, "jsx": "react-native", "noEmit": true, "strict": false }, "exclude": [ "node_modules", "babel.config.js", "metro.config.js", "jest.config.js" ] }
Главное: убрать строку "extends": "expo/tsconfig.base".
Только если ты специально делала TypeScript-проект, и у тебя есть package.json с expo, тогда нужно:
В корне проекта выполнить:
bashnpm install
чтобы подтянулись node_modules.
Убедиться, что в package.json есть "expo": "...".
Тогда вот такой tsconfig.json корректен:
json{ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true } }
Но судя по пути lab8, ты просто делаешь лабораторную, а не большой TS-проект, так что для ЛР проще всего – удалить tsconfig.json или убрать extends.
Если покажешь свой текущий tsconfig.json, могу прямо по нему точечно исправить 👍
"""Лабораторная-21.Жизненные циклы
Цель работы
Разобраться в этапах жизненного цикла React компонент.
Задания для выполнения
import {
Animated,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import {
GestureHandlerRootView,
LongPressGestureHandler,
PanGestureHandler,
PinchGestureHandler,
State,
TapGestureHandler,
} from 'react-native-gesture-handler';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Provider, useDispatch, useSelector } from 'react-redux';
import { createStore } from 'redux';
/* --------------------- ДАННЫЕ ФЕСТИВАЛЯ --------------------- */
const TEAMS = [
{
id: 'neon_waves',
name: 'Neon Waves',
city: 'Алматы',
stadium: 'Главная сцена «Неон»',
points: 95,
},
{
id: 'sunrise_crew',
name: 'Sunrise Crew',
city: 'Астана',
stadium: 'Сцена «Восход»',
points: 92,
},
{
id: 'electric_steppe',
name: 'Electric Steppe',
city: 'Шымкент',
stadium: 'Сцена «Степь»',
points: 88,
},
{
id: 'altair',
name: 'Altair',
city: 'Караганда',
stadium: 'Сцена «Созвездие»',
points: 85,
},
{
id: 'city_lights',
name: 'City Lights',
city: 'Павлодар',
stadium: 'Urban Stage',
points: 81,
},
{
id: 'desert_bloom',
name: 'Desert Bloom',
city: 'Туркестан',
stadium: 'Сцена «Оазис»',
points: 78,
},
{
id: 'crystal_sky',
name: 'Crystal Sky',
city: 'Семей',
stadium: 'Crystal Dome',
points: 76,
},
{
id: 'night_bus',
name: 'Night Bus',
city: 'Костанай',
stadium: 'Night Drive Stage',
points: 73,
},
{
id: 'laser_cats',
name: 'Laser Cats',
city: 'Талдыкорган',
stadium: 'Сцена «Котодром»',
points: 70,
},
{
id: 'vinyl_vibes',
name: 'Vinyl Vibes',
city: 'Атырау',
stadium: 'Vinyl Yard',
points: 67,
},
{
id: 'bubble_pop',
name: 'Bubble Pop',
city: 'Кызылорда',
stadium: 'Сцена «Фейерверк»',
points: 65,
},
{
id: 'skyline',
name: 'Skyline',
city: 'Петропавл',
stadium: 'Skyline Terrace',
points: 63,
},
{
id: 'echo_garden',
name: 'Echo Garden',
city: 'Усть-Каменогорск',
stadium: 'Echo Garden',
points: 61,
},
{
id: 'luna_loops',
name: 'Luna Loops',
city: 'Актау',
stadium: 'Moonlight Stage',
points: 59,
},
];
const MATCHES = [
{
id: 1,
home: 'Neon Waves',
away: 'Sunrise Crew',
date: '12 июля, 19:00',
stadium: 'Главная сцена «Неон»',
},
{
id: 2,
home: 'Electric Steppe',
away: 'Altair',
date: '13 июля, 18:00',
stadium: 'Сцена «Степь»',
},
{
id: 3,
home: 'City Lights',
away: 'Desert Bloom',
date: '14 июля, 20:00',
stadium: 'Urban Stage',
},
{
id: 4,
home: 'Crystal Sky',
away: 'Night Bus',
date: '15 июля, 18:30',
stadium: 'Crystal Dome',
},
{
id: 5,
home: 'Laser Cats',
away: 'Vinyl Vibes',
date: '16 июля, 17:00',
stadium: 'Сцена «Котодром»',
},
{
id: 6,
home: 'Bubble Pop',
away: 'Skyline',
date: '17 июля, 19:30',
stadium: 'Сцена «Фейерверк»',
},
{
id: 7,
home: 'Echo Garden',
away: 'Luna Loops',
date: '18 июля, 20:00',
stadium: 'Echo Garden',
},
{
id: 8,
home: 'Neon Waves',
away: 'Laser Cats',
date: '19 июля, 21:00',
stadium: 'Главная сцена «Неон»',
},
{
id: 9,
home: 'Sunrise Crew',
away: 'City Lights',
date: '20 июля, 20:30',
stadium: 'Сцена «Восход»',
},
];
/* --------------------- REDUX (лаб. 22) --------------------- */
const START_TIMER = 'START_TIMER';
const PAUSE_TIMER = 'PAUSE_TIMER';
const RESET_TIMER = 'RESET_TIMER';
const TICK = 'TICK';
const SELECT_TEAM = 'SELECT_TEAM';
const TOGGLE_FAVORITE_TEAM = 'TOGGLE_FAVORITE_TEAM';
const TOGGLE_FAVORITE_MATCH = 'TOGGLE_FAVORITE_MATCH';
const initialState = {
timerDuration: 90 * 60, // 90 минут
elapsedSeconds: 0,
isPlaying: false,
selectedTeamId: TEAMS[0].id,
favoriteTeamIds: [],
favoriteMatchIds: [],
};
function reducer(state = initialState, action) {
switch (action.type) {
case START_TIMER:
return { ...state, isPlaying: true };
case PAUSE_TIMER:
return { ...state, isPlaying: false };
case RESET_TIMER:
return { ...state, isPlaying: false, elapsedSeconds: 0 };
case TICK: {
if (!state.isPlaying) return state;
if (state.elapsedSeconds >= state.timerDuration) {
return { ...state, isPlaying: false };
}
return { ...state, elapsedSeconds: state.elapsedSeconds + 1 };
}
case SELECT_TEAM:
return { ...state, selectedTeamId: action.payload };
case TOGGLE_FAVORITE_TEAM: {
const id = action.payload;
const exists = state.favoriteTeamIds.includes(id);
return {
...state,
favoriteTeamIds: exists
? state.favoriteTeamIds.filter((t) => t !== id)
: [...state.favoriteTeamIds, id],
};
}
case TOGGLE_FAVORITE_MATCH: {
const id = action.payload;
const exists = state.favoriteMatchIds.includes(id);
return {
...state,
favoriteMatchIds: exists
? state.favoriteMatchIds.filter((m) => m !== id)
: [...state.favoriteMatchIds, id],
};
}
default:
return state;
}
}
const store = createStore(reducer);
/* ----------------- Action creators (лаб. 22) ---------------- */
function startTimer() {
return { type: START_TIMER };
}
function pauseTimer() {
return { type: PAUSE_TIMER };
}
function resetTimer() {
return { type: RESET_TIMER };
}
function tick() {
return { type: TICK };
}
function selectTeam(teamId) {
return { type: SELECT_TEAM, payload: teamId };
}
function toggleFavoriteTeam(teamId) {
return { type: TOGGLE_FAVORITE_TEAM, payload: teamId };
}
function toggleFavoriteMatch(matchId) {
return { type: TOGGLE_FAVORITE_MATCH, payload: matchId };
}
/* --------------------- Утилиты ----------------------------- */
// форматирование таймера
function formatTime(totalSeconds) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return ${String(minutes).padStart(2, '0')}:${String(seconds).padStart( 2, '0' )};
}
// получить команду/артиста по id
function getTeamById(teamId) {
return TEAMS.find((t) => t.id === teamId) || null;
}
// отсортировать по «очкам» (лайкам/рейтингу)
function getSortedTeams(teams) {
return [...teams].sort((a, b) => b.points - a.points);
}
// сгруппировать выступления по дню (часть до запятой)
function groupMatchesByDay(matches) {
return matches.reduce((acc, match) => {
const [day] = match.date.split(',');
if (!acc[day]) acc[day] = [];
acc[day].push(match);
return acc;
}, {});
}
/* ----------- Демонстрация жизненного цикла (лаб. 21) ------- */
class LifecycleDemo extends React.Component {
componentDidMount() {
console.log('LifecycleDemo: componentDidMount');
}
componentDidUpdate(prevProps) {
if (prevProps.watchValue !== this.props.watchValue) {
console.log(
'LifecycleDemo: componentDidUpdate. watchValue:',
prevProps.watchValue,
'→',
this.props.watchValue
);
}
}
componentWillUnmount() {
console.log('LifecycleDemo: componentWillUnmount');
}
render() {
return (
<Text style={styles.lifecycleText}>
Откройте консоль Expo/React Native — здесь видно, как меняются
состояния таймера выступления (жизненный цикл компонента).
</Text>
);
}
}
/* --------------------- Компонент таймера -------------------- */
function TimerSection() {
const dispatch = useDispatch();
const { timerDuration, elapsedSeconds, isPlaying } = useSelector(
(state) => state
);
useEffect(() => {
let interval;
if (isPlaying) {
interval = setInterval(() => {
dispatch(tick());
}, 1000);
}
return () => {
if (interval) clearInterval(interval);
};
}, [dispatch, isPlaying]);
const progress = Math.min(elapsedSeconds / timerDuration, 1);
const lifecycleState =
isPlaying && elapsedSeconds === 0
? 'starting'
: isPlaying
? 'running'
: elapsedSeconds > 0
? 'paused'
: 'idle';
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Таймер выступления</Text>
text<View style={styles.timerRow}> <Text style={styles.timerValue}>{formatTime(elapsedSeconds)}</Text> <Text style={styles.timerOf}> / {formatTime(timerDuration)}</Text> </View> <View style={styles.progressBar}> <View style={[ styles.progressBarFill, { width: `${progress * 100}%` }, ]} /> </View> <View style={styles.timerButtonsRow}> <TouchableOpacity style={styles.buttonPrimary} onPress={() => isPlaying ? dispatch(pauseTimer()) : dispatch(startTimer()) } > <Text style={styles.buttonPrimaryText}> {isPlaying ? 'Пауза' : 'Старт'} </Text> </TouchableOpacity> <TouchableOpacity style={styles.buttonSecondary} onPress={() => dispatch(resetTimer())} > <Text style={styles.buttonSecondaryText}>Сброс</Text> </TouchableOpacity> </View> <LifecycleDemo watchValue={lifecycleState} /> </View>
);
}
/* --------------- Header + Pinch (лаб. 23 – Pinch) ----------- */
function HeaderSection() {
const scale = useRef(new Animated.Value(1)).current;
const onPinchEvent = Animated.event(
[{ nativeEvent: { scale } }],
{ useNativeDriver: true }
);
const onPinchStateChange = (event) => {
const { state } = event.nativeEvent;
if (
state === State.END ||
state === State.CANCELLED ||
state === State.FAILED
) {
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
}).start();
}
};
return (
<View style={styles.header}>
<View style={styles.headerLeft}>
<Text style={styles.appTitle}>Музыкальный фестиваль «Neon Beats»</Text>
<Text style={styles.appSubtitle}>
Лето 2025 • 3 сцены • 14 артистов
</Text>
</View>
text<PinchGestureHandler onGestureEvent={onPinchEvent} onHandlerStateChange={onPinchStateChange} > {/* ВАЖНО: Animated.Image, чтобы scale (Animated.Value) был валидным */} <Animated.Image source={{ uri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Music_Icon.png/512px-Music_Icon.png', }} style={[styles.logo, { transform: [{ scale }] }]} resizeMode="contain" /> </PinchGestureHandler> </View>
);
}
/* --- Блок с информацией о выбранном артисте ----------------- */
function SelectedTeamInfoSection() {
const { selectedTeamId, favoriteTeamIds } = useSelector((state) => ({
selectedTeamId: state.selectedTeamId,
favoriteTeamIds: state.favoriteTeamIds,
}));
const team = getTeamById(selectedTeamId);
if (!team) return null;
const isFavorite = favoriteTeamIds.includes(team.id);
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Выбранный артист</Text>
<View style={styles.selectedTeamCard}>
<View style={styles.selectedTeamHeaderRow}>
<Text style={styles.selectedTeamName}>{team.name}</Text>
{isFavorite && (
<Text style={styles.selectedTeamStar}>★</Text>
)}
</View>
<Text style={styles.selectedTeamMeta}>
{team.city} • {team.stadium}
</Text>
<Text style={styles.selectedTeamPoints}>
Лайков: {team.points}
</Text>
</View>
</View>
);
}
/* ----------- Расписание фестиваля (календарь) --------------- */
function MatchCalendarSection({ onClose }) {
const grouped = groupMatchesByDay(MATCHES);
const days = Object.keys(grouped);
return (
<View style={styles.section}>
<View style={styles.calendarHeaderRow}>
<Text style={styles.sectionTitle}>Расписание фестиваля</Text>
{onClose && (
<TouchableOpacity onPress={onClose}>
<Text style={styles.calendarCloseText}>Скрыть</Text>
</TouchableOpacity>
)}
</View>
text{days.map((day) => ( <View key={day} style={styles.calendarDayBlock}> <Text style={styles.calendarDateText}>{day}</Text> {grouped[day].map((match) => ( <View key={match.id} style={styles.calendarMatchRow}> <Text style={styles.calendarMatchTeams}> {match.home} — {match.away} </Text> <Text style={styles.calendarMatchMeta}> {match.date.split(',')[1]?.trim()} • {match.stadium} </Text> </View> ))} </View> ))} <Text style={styles.hintText}> Смахните карточку «Главный концерт дня», чтобы открыть расписание. </Text> </View>
);
}
/* -------- Главный концерт + Pan (лаб. 23 – Pan) ------------- */
function FeaturedMatchCard({ onOpenCalendar }) {
const match = MATCHES[0];
const translateX = useRef(new Animated.Value(0)).current;
const onPanEvent = Animated.event(
[{ nativeEvent: { translationX: translateX } }],
{ useNativeDriver: true }
);
const onPanStateChange = (event) => {
const { state, translationX } = event.nativeEvent;
textif ( state === State.END || state === State.CANCELLED || state === State.FAILED ) { if ( Math.abs(translationX) > 80 && typeof onOpenCalendar === 'function' ) { onOpenCalendar(); } Animated.spring(translateX, { toValue: 0, useNativeDriver: true, }).start(); }
};
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Главный концерт дня</Text>
<PanGestureHandler
onGestureEvent={onPanEvent}
onHandlerStateChange={onPanStateChange}
>
<Animated.View
style={[
styles.featuredCard,
{ transform: [{ translateX }] },
]}
>
<Text style={styles.featuredTeams}>
{match.home} — {match.away}
</Text>
<Text style={styles.featuredMeta}>
{match.date} • {match.stadium}
</Text>
<Text style={styles.featuredHint}>
Потяни карточку в сторону, чтобы открыть расписание
выступлений (жест Pan).
</Text>
</Animated.View>
</PanGestureHandler>
</View>
);
}
/* -------- Артисты + Tap (лаб. 23 – Tap) --------------------- */
function TeamsSection() {
const dispatch = useDispatch();
const { selectedTeamId, favoriteTeamIds } = useSelector((state) => ({
selectedTeamId: state.selectedTeamId,
favoriteTeamIds: state.favoriteTeamIds,
}));
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Артисты фестиваля</Text>
{TEAMS.map((team) => {
const selected = team.id === selectedTeamId;
const isFavorite = favoriteTeamIds.includes(team.id);
textreturn ( <TapGestureHandler key={team.id} onHandlerStateChange={(event) => { if (event.nativeEvent.state === State.END) { dispatch(selectTeam(team.id)); } }} > <View style={[ styles.teamCard, selected && styles.teamCardSelected, ]} > <View> <Text style={styles.teamName}>{team.name}</Text> <Text style={styles.teamMeta}> {team.city} • {team.stadium} </Text> </View> <View style={styles.teamRight}> <Text style={styles.teamPoints}> {team.points} лайков </Text> <TouchableOpacity onPress={() => dispatch(toggleFavoriteTeam(team.id))} > <Text style={[ styles.favoriteIcon, isFavorite && styles.favoriteIconActive, ]} > {isFavorite ? '★' : '☆'} </Text> </TouchableOpacity> </View> </View> </TapGestureHandler> ); })} <Text style={styles.hintText}> Короткое нажатие по артисту выбирает его (Tap). Звёздочка — избранные артисты. </Text> </View>
);
}
/* --------- Выступления + LongPress (лаб. 23 – LongPress) ---- */
function MatchesSection() {
const dispatch = useDispatch();
const { favoriteMatchIds } = useSelector((state) => ({
favoriteMatchIds: state.favoriteMatchIds,
}));
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Ближайшие выступления</Text>
{MATCHES.map((match) => {
const isFavorite = favoriteMatchIds.includes(match.id);
return (
<LongPressGestureHandler
key={match.id}
minDurationMs={600}
onHandlerStateChange={(event) => {
if (event.nativeEvent.state === State.ACTIVE) {
dispatch(toggleFavoriteMatch(match.id));
}
}}
>
<View
style={[
styles.matchCard,
isFavorite && styles.matchCardFavorite,
]}
>
<View>
<Text style={styles.matchTeams}>
{match.home} — {match.away}
</Text>
<Text style={styles.matchMeta}>
{match.date} • {match.stadium}
</Text>
</View>
<Text
style={[
styles.favoriteIcon,
isFavorite && styles.favoriteIconActive,
]}
>
{isFavorite ? '★' : '☆'}
</Text>
</View>
</LongPressGestureHandler>
);
})}
<Text style={styles.hintText}>
Долгое нажатие по выступлению добавляет его в избранное (LongPress).
</Text>
</View>
);
}
/* --------------- Рейтинг артистов --------------------------- */
function StandingsSection() {
const favoriteTeamIds = useSelector((state) => state.favoriteTeamIds);
const sortedTeams = getSortedTeams(TEAMS);
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Рейтинг артистов</Text>
{sortedTeams.map((team, index) => {
const isFavorite = favoriteTeamIds.includes(team.id);
return (
<View key={team.id} style={styles.standingsRow}>
<Text style={styles.standingsPos}>{index + 1}</Text>
<Text style={styles.standingsName}>{team.name}</Text>
<View style={styles.standingsRight}>
{isFavorite && (
<Text style={styles.standingsFavoriteMark}>★</Text>
)}
<Text style={styles.standingsPoints}>
{team.points}
</Text>
</View>
</View>
);
})}
<Text style={styles.hintText}>
Звёздочкой отмечены артисты, которых вы добавили в избранное.
</Text>
</View>
);
}
/* ------------------------ Главный экран --------------------- */
function MainScreen() {
const [isCalendarVisible, setCalendarVisible] = useState(false);
const handleOpenCalendar = () => setCalendarVisible(true);
const handleCloseCalendar = () => setCalendarVisible(false);
return (
<SafeAreaView style={styles.safeArea}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<HeaderSection />
<FeaturedMatchCard onOpenCalendar={handleOpenCalendar} />
{isCalendarVisible && (
<MatchCalendarSection onClose={handleCloseCalendar} />
)}
<TimerSection />
<SelectedTeamInfoSection />
<TeamsSection />
<MatchesSection />
<StandingsSection />
</ScrollView>
</SafeAreaView>
);
}
/* --------------------- Root + Provider ---------------------- */
function App() {
return (
<Provider store={store}>
<GestureHandlerRootView style={{ flex: 1 }}>
<MainScreen />
</GestureHandlerRootView>
</Provider>
);
}
export default App;
/* -------------------------- Стили --------------------------- */
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#050816', // тёмный неоновый фон
},
scrollContent: {
padding: 16,
paddingBottom: 32,
},
/* header */
header: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
marginTop: 8,
},
headerLeft: {
flex: 1,
paddingRight: 12,
},
appTitle: {
fontSize: 22,
fontWeight: '700',
color: '#fdf2ff', // мягкий розовый-белый
},
appSubtitle: {
marginTop: 4,
fontSize: 13,
color: '#a5b4fc', // индиго
},
logo: {
width: 64,
height: 64,
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
color: '#e5e7eb',
marginBottom: 8,
},
/* featured concert */
featuredCard: {
backgroundColor: '#1d1135', // фиолетовый блок
padding: 16,
borderRadius: 16,
shadowColor: '#000',
shadowOpacity: 0.35,
shadowRadius: 14,
shadowOffset: { width: 0, height: 6 },
},
featuredTeams: {
fontSize: 16,
fontWeight: '600',
color: '#faf5ff',
marginBottom: 4,
},
featuredMeta: {
fontSize: 13,
color: '#c4b5fd',
},
featuredHint: {
marginTop: 8,
fontSize: 12,
color: '#f9a8d4',
},
/* timer */
timerRow: {
flexDirection: 'row',
alignItems: 'flex-end',
},
timerValue: {
fontSize: 32,
fontWeight: '700',
color: '#4ade80', // ярко-зелёный
},
timerOf: {
marginLeft: 8,
marginBottom: 4,
fontSize: 14,
color: '#e5e7eb',
},
progressBar: {
marginTop: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#1f2937',
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
backgroundColor: '#f97316', // оранжевый прогресс
},
timerButtonsRow: {
flexDirection: 'row',
marginTop: 12,
},
buttonPrimary: {
flex: 1,
backgroundColor: '#f97316',
borderRadius: 9999,
paddingVertical: 10,
alignItems: 'center',
marginRight: 8,
},
buttonSecondary: {
width: 100,
borderRadius: 9999,
borderWidth: 1,
borderColor: '#22c55e',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
},
buttonPrimaryText: {
color: '#1b0b0f',
fontWeight: '600',
},
buttonSecondaryText: {
color: '#bbf7d0',
fontWeight: '500',
},
lifecycleText: {
marginTop: 10,
fontSize: 11,
color: '#9ca3af',
},
/* selected artist card */
selectedTeamCard: {
padding: 12,
borderRadius: 12,
backgroundColor: '#020617',
borderWidth: 1,
borderColor: '#f472b6',
},
selectedTeamHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
},
selectedTeamName: {
flex: 1,
fontSize: 16,
fontWeight: '600',
color: '#f9fafb',
},
selectedTeamStar: {
fontSize: 18,
color: '#facc15',
marginLeft: 4,
},
selectedTeamMeta: {
fontSize: 12,
color: '#a5b4fc',
},
selectedTeamPoints: {
marginTop: 4,
fontSize: 13,
fontWeight: '500',
color: '#4ade80',
},
/* artists */
teamCard: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 12,
backgroundColor: '#020617',
borderWidth: 1,
borderColor: '#1f2937',
marginBottom: 8,
},
teamCardSelected: {
borderColor: '#22d3ee',
backgroundColor: '#0f172a',
},
teamName: {
fontSize: 15,
fontWeight: '600',
color: '#f9fafb',
},
teamMeta: {
marginTop: 2,
fontSize: 12,
color: '#9ca3af',
},
teamRight: {
marginLeft: 'auto',
alignItems: 'flex-end',
},
teamPoints: {
fontSize: 13,
fontWeight: '500',
color: '#facc15',
},
/* performances */
matchCard: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 12,
backgroundColor: '#020617',
borderWidth: 1,
borderColor: '#1f2937',
marginBottom: 8,
},
matchCardFavorite: {
borderColor: '#e879f9',
backgroundColor: '#111827',
},
matchTeams: {
fontSize: 14,
fontWeight: '600',
color: '#f9fafb',
},
matchMeta: {
marginTop: 2,
fontSize: 12,
color: '#9ca3af',
},
/* standings */
standingsRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: '#111827',
},
standingsPos: {
width: 24,
fontSize: 13,
fontWeight: '600',
color: '#9ca3af',
},
standingsName: {
flex: 1,
fontSize: 14,
color: '#e5e7eb',
},
standingsRight: {
flexDirection: 'row',
alignItems: 'center',
},
standingsFavoriteMark: {
fontSize: 14,
color: '#facc15',
marginRight: 6,
},
standingsPoints: {
width: 40,
textAlign: 'right',
fontSize: 14,
fontWeight: '600',
color: '#f9fafb',
},
/* calendar */
calendarHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
},
calendarCloseText: {
fontSize: 13,
color: '#7dd3fc',
},
calendarDayBlock: {
marginTop: 10,
padding: 12,
borderRadius: 12,
backgroundColor: '#020617',
borderWidth: 1,
borderColor: '#22d3ee',
},
calendarDateText: {
fontSize: 13,
fontWeight: '600',
color: '#7dd3fc',
marginBottom: 4,
},
calendarMatchRow: {
marginTop: 4,
},
calendarMatchTeams: {
fontSize: 14,
fontWeight: '600',
color: '#f9fafb',
},
calendarMatchMeta: {
fontSize: 12,
color: '#9ca3af',
},
/* общее */
favoriteIcon: {
fontSize: 20,
color: '#64748b',
marginLeft: 8,
},
favoriteIconActive: {
color: '#fb7185',
},
hintText: {
marginTop: 6,
fontSize: 11,
color: '#9ca3af',
},
});
Коротко: да, задание по жизненному циклу (ЛР-21) в этом коде уже реализовано.
Дальше разберём где именно и как это работает.
Вот ключевой фрагмент:
js/* ----------- Демонстрация жизненного цикла (лаб. 21) ------- */ class LifecycleDemo extends React.Component { componentDidMount() { console.log('LifecycleDemo: componentDidMount'); } componentDidUpdate(prevProps) { if (prevProps.watchValue !== this.props.watchValue) { console.log( 'LifecycleDemo: componentDidUpdate. watchValue:', prevProps.watchValue, '→', this.props.watchValue ); } } componentWillUnmount() { console.log('LifecycleDemo: componentWillUnmount'); } render() { return ( <Text style={styles.lifecycleText}> Откройте консоль Expo/React Native — здесь видно, как меняются состояния таймера выступления (жизненный цикл компонента). </Text> ); } }
Здесь ты как раз:
class LifecycleDemo extends React.Component,componentDidMount — вызывается один раз после первого рендера;componentDidUpdate — вызывается при каждом обновлении пропсов/стейта;componentWillUnmount — вызывается перед удалением компонента с экрана;console.log(...) → то есть “вывести результат в консоль” — выполнено.Это ровно то, что просят в пункте:
Подключите эти функции и выведите результат их работы в консоль.
В TimerSection ты передаёшь в него проп watchValue:
jsfunction TimerSection() { // ... const lifecycleState = isPlaying && elapsedSeconds === 0 ? 'starting' : isPlaying ? 'running' : elapsedSeconds > 0 ? 'paused' : 'idle'; return ( <View style={styles.section}> {/* ... таймер ... */} <LifecycleDemo watchValue={lifecycleState} /> </View> ); }
Что происходит:
TimerSection рендерится в первый раз → LifecycleDemo монтируется, срабатывает componentDidMount → лог в консоль.isPlaying, elapsedSeconds) → вычисляется новое lifecycleState и приходит в watchValue.
watchValue изменилось, в componentDidUpdate ты выводишь в консоль, что было до и что стало после.<LifecycleDemo ... /> (например, условно), при его исчезновении сработает componentWillUnmount и тоже выведет лог.То есть ты наглядно показываешь, как компонент:
Плюс к классическому жизненному циклу, в этом же коде ты используешь и хуки, которые являются аналогом жизненного цикла для функциональных компонентов:
jsfunction TimerSection() { // ... useEffect(() => { let interval; if (isPlaying) { interval = setInterval(() => { dispatch(tick()); }, 1000); } return () => { if (interval) clearInterval(interval); }; }, [dispatch, isPlaying]); }
Здесь:
useEffect(..., [isPlaying]) играет роль:
componentDidMount + componentDidUpdate (когда isPlaying меняется);return () => { ... } внутри useEffect — аналог componentWillUnmount
(очистка интервала, когда компонент размонтируется или isPlaying изменится).Это уже более «современный» подход, но для ЛР-21 акцент обычно именно на классическом классовом компоненте, который у тебя есть (LifecycleDemo).
Да, твой код соответствует заданию по ЛР-21:
componentDidMount, componentDidUpdate, componentWillUnmount);useEffect в функциональном компоненте.Если хочешь, могу отдельно написать маленькую упрощённую версию проекта только под ЛР-21 (без Redux и жестов) — чисто для того, чтобы в отчёте было супер-прозрачно.