3-4 bo'lim mashqlari — kontaktlar ilovasi

~45 daq · React Native · Ro'yxatlar va formalar

Vazifa

Stil, layout, ro'yxatlar va formalar bo'yicha o'rganganlarni bitta ilovada birlashtiramiz. Ilova — kontaktlar daftari:

  1. Kontaktlar ro'yxati — harflar bo'yicha guruhlangan (SectionList), yopishqoq sarlavhalar
  2. Tepada qidiruv maydoni — ism yoki telefon bo'yicha
  3. Har bir kontakt — avatar (ismning bosh harfi, rangli doira), ism, telefon
  4. Pastki o'ng burchakda "+" tugmasi — modal forma ochadi
  5. Forma: ism, telefon, email (ixtiyoriy) — React Hook Form + Zod, klaviatura to'g'ri ishlaydi
  6. Kontaktni uzoq bosish — o'chirishni tasdiqlovchi Alert
  7. 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

  1. ⭐ Kontaktni bosganda — Linking.openURL('tel:...') bilan qo'ng'iroq qilish (43-dars)
  2. ⭐⭐ Kontaktni tahrirlash: bosilganda forma to'ldirilgan holda ochilsin (reset(kontakt))
  3. ⭐⭐ Planshetda (width >= 768) ro'yxat chapda, tanlangan kontakt tafsiloti o'ngda (15-dars)
  4. ⭐⭐⭐ Sevimlilar: yulduzcha belgisi va ro'yxat tepasida alohida "Sevimlilar" bo'limi

O'zingizni tekshiring

  • ScrollView + map va FlatList farqi nima? Qachon qaysi biri?
  • keyExtractor da indeksni ishlatish nima uchun xavfli?
  • onEndReached ni nima uchun himoyalash kerak?
  • React Hook Form da Controller nima 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.