5-6 bo'lim mashqlari — yangiliklar ilovasi
Vazifa
Navigatsiya, API, kesh va holat boshqaruvini bitta ilovada birlashtiramiz. Ma'lumot manbai — bepul test API DummyJSON (https://dummyjson.com/posts).
Talablar:
- Ikki tab: Lenta va Saqlanganlar
- Lenta — cheksiz scroll (
useInfiniteQuery), pull-to-refresh - Postni bosish —
/post/[id]ekrani: to'liq matn va izohlar (/posts/{id}/comments) - Post ekranida "Saqlash" tugmasi — Saqlanganlar tabiga qo'shadi (Zustand + persist)
- Saqlanganlar tabi badge bilan, saqlanganlar soni
- Lenta tepasida qidiruv (
/posts/search?q=...), 400 ms kechikish bilan (debounce) - Offline banner; kesh diskda — ilova internetsiz ochilganda oxirgi lenta ko'rinadi
- Xato holatida — xabar va "Qayta urinish"
Tuzilma
app/
├── _layout.tsx ← QueryClient, persist, Stack
├── (tabs)/
│ ├── _layout.tsx ← Tabs + badge
│ ├── index.tsx ← Lenta
│ └── saqlangan.tsx
└── post/[id].tsx
api/
└── postlar.ts ← so'rovlar va hook lar
stores/
└── saqlangan.ts
hooks/
└── useDebounce.ts
components/
├── PostKarta.tsx
├── XatoEkran.tsx
└── OfflineBanner.tsx ← 36-darsdan
api/postlar.ts
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
const BASE = 'https://dummyjson.com';
const HAJM = 15;
export type Post = { id: number; title: string; body: string; tags: string[]; reactions: { likes: number }; views: number };
type PostlarJavob = { posts: Post[]; total: number; skip: number; limit: number };
export type Izoh = { id: number; body: string; user: { fullName: string } };
async function ol<T>(url: string): Promise<T> {
const javob = await fetch(url);
if (!javob.ok) throw new Error(`Server xatosi (${javob.status})`);
return javob.json();
}
export function useLenta(qidiruv: string) {
return useInfiniteQuery({
queryKey: ['postlar', qidiruv],
queryFn: ({ pageParam }) =>
ol<PostlarJavob>(
qidiruv
? `${BASE}/posts/search?q=${encodeURIComponent(qidiruv)}&limit=${HAJM}&skip=${pageParam}`
: `${BASE}/posts?limit=${HAJM}&skip=${pageParam}`
),
initialPageParam: 0,
getNextPageParam: (oxirgi) => {
const keyingi = oxirgi.skip + oxirgi.limit;
return keyingi < oxirgi.total ? keyingi : undefined;
},
});
}
export function usePost(id: string) {
return useQuery({ queryKey: ['post', id], queryFn: () => ol<Post>(`${BASE}/posts/${id}`), enabled: !!id });
}
export function useIzohlar(id: string) {
return useQuery({
queryKey: ['izohlar', id],
queryFn: () => ol<{ comments: Izoh[] }>(`${BASE}/posts/${id}/comments`).then((d) => d.comments),
enabled: !!id,
});
}
encodeURIComponent — qidiruvdagi bo'sh joy va maxsus belgilar URL ni buzmasin.
stores/saqlangan.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Post } from '@/api/postlar';
type SaqlanganPost = Pick<Post, 'id' | 'title' | 'body'>;
type Holat = {
postlar: SaqlanganPost[];
almashtir: (p: SaqlanganPost) => void;
};
export const useSaqlangan = create<Holat>()(
persist(
(set) => ({
postlar: [],
almashtir: (p) =>
set((s) => ({
postlar: s.postlar.some((x) => x.id === p.id)
? s.postlar.filter((x) => x.id !== p.id)
: [p, ...s.postlar],
})),
}),
{ name: 'saqlangan', storage: createJSONStorage(() => AsyncStorage) }
)
);
hooks/useDebounce.ts
import { useEffect, useState } from 'react';
export function useDebounce<T>(qiymat: T, kechikish = 400) {
const [kechiktirilgan, setKechiktirilgan] = useState(qiymat);
useEffect(() => {
const t = setTimeout(() => setKechiktirilgan(qiymat), kechikish);
return () => clearTimeout(t);
}, [qiymat, kechikish]);
return kechiktirilgan;
}
Har bir harf uchun so'rov yubormaslik: foydalanuvchi yozishni to'xtatgandan 400 ms keyin.
app/(tabs)/index.tsx — Lenta
import { useState } from 'react';
import { FlatList, TextInput, View, ActivityIndicator, Text } from 'react-native';
import { useLenta } from '@/api/postlar';
import { useDebounce } from '@/hooks/useDebounce';
import { PostKarta } from '@/components/PostKarta';
import { XatoEkran } from '@/components/XatoEkran';
export default function Lenta() {
const [qidiruv, setQidiruv] = useState('');
const q = useDebounce(qidiruv.trim());
const { data, isPending, isError, error, refetch, isRefetching, fetchNextPage, hasNextPage, isFetchingNextPage } = useLenta(q);
const postlar = data?.pages.flatMap((p) => p.posts) ?? [];
return (
<View style={{ flex: 1 }}>
<TextInput
value={qidiruv}
onChangeText={setQidiruv}
placeholder="Qidirish..."
style={{ margin: 12, padding: 12, backgroundColor: '#f1f5f9', borderRadius: 10, fontSize: 16 }}
autoCorrect={false}
clearButtonMode="while-editing"
/>
{isPending ? (
<ActivityIndicator size="large" style={{ marginTop: 40 }} />
) : isError ? (
<XatoEkran xabar={error.message} qaytaUrinish={refetch} />
) : (
<FlatList
data={postlar}
keyExtractor={(p) => String(p.id)}
renderItem={({ item }) => <PostKarta post={item} />}
contentContainerStyle={{ padding: 12, gap: 10 }}
onEndReached={() => hasNextPage && !isFetchingNextPage && fetchNextPage()}
onEndReachedThreshold={0.5}
refreshing={isRefetching && !isFetchingNextPage}
onRefresh={refetch}
keyboardDismissMode="on-drag"
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator style={{ margin: 16 }} /> : null}
ListEmptyComponent={<Text style={{ textAlign: 'center', marginTop: 40, color: '#64748b' }}>Hech narsa topilmadi</Text>}
/>
)}
</View>
);
}
components/PostKarta.tsx
import { memo } from 'react';
import { Link } from 'expo-router';
import { Pressable, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { Post } from '@/api/postlar';
export const PostKarta = memo(function PostKarta({ post }: { post: Post }) {
return (
<Link href={{ pathname: '/post/[id]', params: { id: post.id } }} asChild>
<Pressable style={({ pressed }) => ({ backgroundColor: 'white', padding: 14, borderRadius: 12, gap: 6, opacity: pressed ? 0.8 : 1 })}>
<Text style={{ fontSize: 16, fontWeight: '700' }} numberOfLines={2}>{post.title}</Text>
<Text style={{ color: '#475569' }} numberOfLines={3}>{post.body}</Text>
<View style={{ flexDirection: 'row', gap: 16 }}>
<Text style={{ color: '#94a3b8', fontSize: 13 }}><Ionicons name="heart-outline" /> {post.reactions.likes}</Text>
<Text style={{ color: '#94a3b8', fontSize: 13 }}><Ionicons name="eye-outline" /> {post.views}</Text>
</View>
</Pressable>
</Link>
);
});
app/post/[id].tsx
import { ScrollView, Text, View, ActivityIndicator, Pressable } from 'react-native';
import { Stack, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { usePost, useIzohlar } from '@/api/postlar';
import { useSaqlangan } from '@/stores/saqlangan';
import { XatoEkran } from '@/components/XatoEkran';
export default function PostEkran() {
const { id } = useLocalSearchParams<{ id: string }>();
const post = usePost(id);
const izohlar = useIzohlar(id);
const saqlangan = useSaqlangan((s) => s.postlar.some((p) => p.id === Number(id)));
const almashtir = useSaqlangan((s) => s.almashtir);
if (post.isPending) return <ActivityIndicator size="large" style={{ flex: 1 }} />;
if (post.isError) return <XatoEkran xabar={post.error.message} qaytaUrinish={post.refetch} />;
const p = post.data;
return (
<ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}>
<Stack.Screen
options={{
title: 'Post',
headerRight: () => (
<Pressable onPress={() => almashtir({ id: p.id, title: p.title, body: p.body })} hitSlop={10}
accessibilityLabel={saqlangan ? 'Saqlanganlardan olib tashlash' : 'Saqlash'}>
<Ionicons name={saqlangan ? 'bookmark' : 'bookmark-outline'} size={22} color="#0284c7" />
</Pressable>
),
}}
/>
<Text style={{ fontSize: 22, fontWeight: '800' }}>{p.title}</Text>
<Text style={{ fontSize: 16, lineHeight: 24, color: '#334155' }}>{p.body}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
{p.tags.map((t) => (
<Text key={t} style={{ backgroundColor: '#e0f2fe', color: '#0369a1', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999 }}>#{t}</Text>
))}
</View>
<Text style={{ fontSize: 18, fontWeight: '700', marginTop: 12 }}>Izohlar</Text>
{izohlar.isPending ? <ActivityIndicator /> :
izohlar.data?.map((iz) => (
<View key={iz.id} style={{ backgroundColor: '#f8fafc', padding: 12, borderRadius: 10 }}>
<Text style={{ fontWeight: '600' }}>{iz.user.fullName}</Text>
<Text style={{ color: '#475569', marginTop: 4 }}>{iz.body}</Text>
</View>
))}
</ScrollView>
);
}
Izohlar kam (5-10 ta) — ScrollView + map normal (8-dars).
app/(tabs)/_layout.tsx — badge
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSaqlangan } from '@/stores/saqlangan';
export default function TabLayout() {
const soni = useSaqlangan((s) => s.postlar.length);
return (
<Tabs screenOptions={{ tabBarActiveTintColor: '#0284c7' }}>
<Tabs.Screen name="index" options={{ title: 'Lenta',
tabBarIcon: ({ color, size, focused }) => <Ionicons name={focused ? 'newspaper' : 'newspaper-outline'} size={size} color={color} /> }} />
<Tabs.Screen name="saqlangan" options={{ title: 'Saqlanganlar', tabBarBadge: soni || undefined,
tabBarIcon: ({ color, size, focused }) => <Ionicons name={focused ? 'bookmark' : 'bookmark-outline'} size={size} color={color} /> }} />
</Tabs>
);
}
app/_layout.tsx — 36-darsdagi PersistQueryClientProvider + OfflineBanner + Stack ((tabs) sarlavhasiz, post/[id] sarlavha bilan). saqlangan.tsx — useSaqlangan dan FlatList, bo'sh holat matni bilan.
Qo'shimcha vazifalar
- ⭐ Saqlanganlar ekranida chapga surib o'chirish (46-darsdan keyin)
- ⭐⭐ Post ekranida "Yoqtirish" — optimistik yangilash bilan (
dummyjson.com/posts/{id}gaPUT) - ⭐⭐ Teg bosilganda — shu teg bo'yicha postlar ekrani (
/posts/tag/{teg}) - ⭐⭐⭐ Planshetda — chapda lenta, o'ngda tanlangan post (15-dars)
O'zingizni tekshiring
pushvareplacefarqi? Kirishdan keyin qaysi biri?(tabs)papka nomidagi qavslar nimani bildiradi?- Deep link bilan kelgan parametrga nima uchun ishonmaslik kerak?
- TanStack Query keshi foydalanuvchiga nima beradi?
- Server holati va klient holati farqi? Ular qayerda saqlanadi?
- Token ni nima uchun AsyncStorage da saqlamaslik kerak?
Keyingi bo'lim
7-bo'lim: Qurilma imkoniyatlari — ruxsatlar, kamera, galereya, joylashuv, push bildirishnomalar.