3-4 bo'lim mashqlari — kontaktlar ilovasi
Vazifa
Stil, layout, ro'yxatlar va formalar bo'yicha o'rganganlarni bitta ilovada birlashtiramiz. Ilova — kontaktlar daftari:
- Kontaktlar ro'yxati — harflar bo'yicha guruhlangan (
SectionList), yopishqoq sarlavhalar - Tepada qidiruv maydoni — ism yoki telefon bo'yicha
- Har bir kontakt — avatar (ismning bosh harfi, rangli doira), ism, telefon
- Pastki o'ng burchakda "+" tugmasi — modal forma ochadi
- Forma: ism, telefon, email (ixtiyoriy) — React Hook Form + Zod, klaviatura to'g'ri ishlaydi
- Kontaktni uzoq bosish — o'chirishni tasdiqlovchi
Alert - Qorong'i rejimni qo'llab-quvvatlash, safe area to'g'ri
Hozircha ma'lumot faqat xotirada (useState). 34-darsda AsyncStorage bilan saqlashni qo'shamiz.
Avval o'zingiz yozing. Quyida — to'liq yechim.
Fayl tuzilmasi
app/
├── _layout.tsx
└── index.tsx
components/
├── Avatar.tsx
├── KontaktBand.tsx
├── KontaktForma.tsx
└── FormInput.tsx ← 21-darsdan
schemas/
└── kontakt.ts
hooks/
└── useMavzu.ts ← 17-darsdan
schemas/kontakt.ts
import { z } from 'zod';
export const kontaktSxema = z.object({
ism: z.string().trim().min(2, 'Ism kamida 2 harf'),
telefon: z.string().regex(/^\+998\d{9}$/, 'Format: +998901234567'),
email: z.union([z.literal(''), z.string().trim().email("Email noto'g'ri")]),
});
export type KontaktForma = z.infer<typeof kontaktSxema>;
export type Kontakt = KontaktForma & { id: string };
email ixtiyoriy: bo'sh qator yoki to'g'ri email.
components/Avatar.tsx
import { View, Text } from 'react-native';
const RANGLAR = ['#0284c7', '#16a34a', '#9333ea', '#ea580c', '#db2777', '#0891b2'];
export function Avatar({ ism, olcham = 44 }: { ism: string; olcham?: number }) {
const rang = RANGLAR[ism.charCodeAt(0) % RANGLAR.length];
return (
<View style={{ width: olcham, height: olcham, borderRadius: olcham / 2, backgroundColor: rang,
justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ color: 'white', fontWeight: '700', fontSize: olcham * 0.4 }}>
{ism.charAt(0).toUpperCase()}
</Text>
</View>
);
}
Rang ismdan hisoblanadi — bir xil odam doim bir xil rangda.
components/KontaktBand.tsx
import { memo } from 'react';
import { Pressable, View, Text, StyleSheet } from 'react-native';
import { Avatar } from './Avatar';
import { useMavzu } from '@/hooks/useMavzu';
import type { Kontakt } from '@/schemas/kontakt';
type Props = { kontakt: Kontakt; onUzoqBosish: (k: Kontakt) => void };
export const KontaktBand = memo(function KontaktBand({ kontakt, onUzoqBosish }: Props) {
const m = useMavzu();
return (
<Pressable
onLongPress={() => onUzoqBosish(kontakt)}
android_ripple={{ color: m.chegara }}
style={({ pressed }) => [styles.band, { backgroundColor: pressed ? m.fonIkkinchi : m.fon }]}
>
<Avatar ism={kontakt.ism} />
<View style={{ flex: 1 }}>
<Text style={[styles.ism, { color: m.matn }]}>{kontakt.ism}</Text>
<Text style={{ color: m.matnIkkinchi }}>{kontakt.telefon}</Text>
</View>
</Pressable>
);
});
const styles = StyleSheet.create({
band: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 10 },
ism: { fontSize: 16, fontWeight: '600' },
});
components/KontaktForma.tsx
import { Modal, View, Text, Pressable, KeyboardAvoidingView, Platform, ScrollView, StyleSheet } from 'react-native';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { kontaktSxema, KontaktForma as FormaTuri } from '@/schemas/kontakt';
import { FormInput } from './FormInput';
import { useMavzu } from '@/hooks/useMavzu';
type Props = { ochiq: boolean; yop: () => void; saqla: (k: FormaTuri) => void };
export function KontaktForma({ ochiq, yop, saqla }: Props) {
const m = useMavzu();
const insets = useSafeAreaInsets();
const { control, handleSubmit, reset } = useForm<FormaTuri>({
resolver: zodResolver(kontaktSxema),
mode: 'onTouched',
defaultValues: { ism: '', telefon: '+998', email: '' },
});
function yopish() {
reset();
yop();
}
return (
<Modal visible={ochiq} animationType="slide" presentationStyle="pageSheet" onRequestClose={yopish}>
<KeyboardAvoidingView style={{ flex: 1, backgroundColor: m.fon }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<View style={[styles.sarlavha, { borderColor: m.chegara }]}>
<Pressable onPress={yopish} hitSlop={10}><Text style={{ color: m.asosiy, fontSize: 16 }}>Bekor</Text></Pressable>
<Text style={[styles.sarlavhaMatn, { color: m.matn }]}>Yangi kontakt</Text>
<Pressable onPress={handleSubmit((d) => { saqla(d); yopish(); })} hitSlop={10}>
<Text style={{ color: m.asosiy, fontSize: 16, fontWeight: '700' }}>Saqlash</Text>
</Pressable>
</View>
<ScrollView contentContainerStyle={{ padding: 16, gap: 14, paddingBottom: insets.bottom + 16 }}
keyboardShouldPersistTaps="handled">
<FormInput control={control} name="ism" label="Ism" autoComplete="name" autoFocus />
<FormInput control={control} name="telefon" label="Telefon" keyboardType="phone-pad" />
<FormInput control={control} name="email" label="Email (ixtiyoriy)" keyboardType="email-address"
autoCapitalize="none" autoCorrect={false} />
</ScrollView>
</KeyboardAvoidingView>
</Modal>
);
}
const styles = StyleSheet.create({
sarlavha: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16, borderBottomWidth: StyleSheet.hairlineWidth },
sarlavhaMatn: { fontSize: 17, fontWeight: '600' },
});
presentationStyle="pageSheet" — iOS da tizim uslubidagi varaq (pastga tortib yopiladi).
app/index.tsx
import { useMemo, useState, useCallback } from 'react';
import { SectionList, View, Text, TextInput, Pressable, Alert, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { KontaktBand } from '@/components/KontaktBand';
import { KontaktForma } from '@/components/KontaktForma';
import { useMavzu } from '@/hooks/useMavzu';
import type { Kontakt, KontaktForma as FormaTuri } from '@/schemas/kontakt';
const boshlangich: Kontakt[] = [
{ id: '1', ism: 'Aziz Karimov', telefon: '+998901112233', email: '' },
{ id: '2', ism: 'Malika Rahimova', telefon: '+998935554433', email: 'malika@mail.uz' },
{ id: '3', ism: 'Bobur Toshev', telefon: '+998977778899', email: '' },
];
export default function Kontaktlar() {
const m = useMavzu();
const [kontaktlar, setKontaktlar] = useState(boshlangich);
const [qidiruv, setQidiruv] = useState('');
const [formaOchiq, setFormaOchiq] = useState(false);
const bolimlar = useMemo(() => {
const q = qidiruv.trim().toLowerCase();
const filtr = kontaktlar
.filter((k) => !q || k.ism.toLowerCase().includes(q) || k.telefon.includes(q))
.sort((a, b) => a.ism.localeCompare(b.ism));
const xarita = new Map<string, Kontakt[]>();
for (const k of filtr) {
const harf = k.ism[0].toUpperCase();
xarita.set(harf, [...(xarita.get(harf) ?? []), k]);
}
return [...xarita].map(([harf, data]) => ({ harf, data }));
}, [kontaktlar, qidiruv]);
const ochirish = useCallback((k: Kontakt) => {
Alert.alert("Kontaktni o'chirish", `${k.ism} o'chirilsinmi?`, [
{ text: 'Bekor', style: 'cancel' },
{ text: "O'chirish", style: 'destructive', onPress: () => setKontaktlar((r) => r.filter((x) => x.id !== k.id)) },
]);
}, []);
function qoshish(data: FormaTuri) {
setKontaktlar((r) => [...r, { ...data, id: String(Date.now()) }]);
}
return (
<SafeAreaView edges={['top']} style={{ flex: 1, backgroundColor: m.fon }}>
<Text style={[styles.sarlavha, { color: m.matn }]}>Kontaktlar</Text>
<View style={[styles.qidiruv, { backgroundColor: m.fonIkkinchi }]}>
<Ionicons name="search" size={18} color={m.matnIkkinchi} />
<TextInput value={qidiruv} onChangeText={setQidiruv} placeholder="Qidirish"
placeholderTextColor={m.matnIkkinchi} style={{ flex: 1, color: m.matn, fontSize: 16 }}
autoCorrect={false} clearButtonMode="while-editing" />
</View>
<SectionList
sections={bolimlar}
keyExtractor={(k) => k.id}
renderItem={({ item }) => <KontaktBand kontakt={item} onUzoqBosish={ochirish} />}
renderSectionHeader={({ section }) => (
<Text style={[styles.harf, { backgroundColor: m.fonIkkinchi, color: m.matnIkkinchi }]}>{section.harf}</Text>
)}
stickySectionHeadersEnabled
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
ListEmptyComponent={<Text style={[styles.bosh, { color: m.matnIkkinchi }]}>Kontakt topilmadi</Text>}
contentContainerStyle={{ paddingBottom: 100 }}
/>
<Pressable onPress={() => setFormaOchiq(true)} accessibilityRole="button" accessibilityLabel="Yangi kontakt"
style={({ pressed }) => [styles.fab, { backgroundColor: m.asosiy }, pressed && { transform: [{ scale: 0.95 }] }]}>
<Ionicons name="add" size={30} color={m.asosiyMatn} />
</Pressable>
<KontaktForma ochiq={formaOchiq} yop={() => setFormaOchiq(false)} saqla={qoshish} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
sarlavha: { fontSize: 30, fontWeight: '800', paddingHorizontal: 16, paddingTop: 8 },
qidiruv: { flexDirection: 'row', alignItems: 'center', gap: 8, margin: 16, paddingHorizontal: 12, paddingVertical: 10, borderRadius: 12 },
harf: { paddingHorizontal: 16, paddingVertical: 4, fontWeight: '700' },
bosh: { textAlign: 'center', marginTop: 40 },
fab: { position: 'absolute', right: 20, bottom: 30, width: 60, height: 60, borderRadius: 30,
justifyContent: 'center', alignItems: 'center', boxShadow: '0 4px 12px rgba(0,0,0,0.2)' },
});
Qo'shimcha vazifalar
- ⭐ Kontaktni bosganda —
Linking.openURL('tel:...')bilan qo'ng'iroq qilish (43-dars) - ⭐⭐ Kontaktni tahrirlash: bosilganda forma to'ldirilgan holda ochilsin (
reset(kontakt)) - ⭐⭐ Planshetda (
width >= 768) ro'yxat chapda, tanlangan kontakt tafsiloti o'ngda (15-dars) - ⭐⭐⭐ Sevimlilar: yulduzcha belgisi va ro'yxat tepasida alohida "Sevimlilar" bo'limi
O'zingizni tekshiring
ScrollView+mapvaFlatListfarqi nima? Qachon qaysi biri?keyExtractorda indeksni ishlatish nima uchun xavfli?onEndReachedni nima uchun himoyalash kerak?- React Hook Form da
Controllernima uchun kerak? - Zod sxemasidan tipni qanday olasiz?
keyboardShouldPersistTaps="handled"qanday muammoni hal qiladi?
Keyingi bo'lim
5-bo'lim: Navigatsiya — Expo Router — fayllar asosidagi yo'llar, stack, tablar, dinamik ekranlar va autentifikatsiya oqimi.