Skip to content

Bab 14 — Networking & REST API

Sampai sekarang seluruh data aplikasimu hidup di memori dan lenyap begitu aplikasi ditutup. Bab ini mengubahnya: kita akan menghubungkan aplikasi ke server, mengirim data ke sana, dan mengambilnya kembali.

Kenapa butuh backend

Ada beberapa hal yang tidak bisa diselesaikan tanpa server:

  • Data bertahan lintas perangkat. Pengguna ganti ponsel, datanya ikut.
  • Data dibagikan antar pengguna. Aplikasi obrolan, media sosial, marketplace.
  • Logika rahasia. Kunci API dan aturan bisnis yang tidak boleh ada di aplikasi, karena aplikasi bisa dibongkar.
  • Pekerjaan berat. Pemrosesan gambar, pengiriman email, penagihan.

Yang berjalan di ponsel disebut klien; yang berjalan di server disebut backend. Keduanya bicara lewat HTTP.

Cara kerja HTTP

Setiap komunikasi terdiri dari permintaan dan respons.

   APLIKASI FLUTTER                          SERVER
   ────────────────                          ──────
         │                                      │
         │  ── PERMINTAAN ──────────────────►   │
         │     Metode: POST                     │
         │     URL: /shopping-list.json         │
         │     Header: Content-Type: json       │
         │     Body: {"nama":"Susu",...}        │
         │                                      │
         │                                   memproses
         │                                      │
         │  ◄────────────────── RESPONS ──      │
         │     Status: 200 OK                   │
         │     Body: {"name":"-Nabc123"}        │
         │                                      │

Metode HTTP

MetodeMaksudContoh
GETAmbil dataMuat daftar belanja
POSTBuat data baruTambah item baru
PUTGanti data seluruhnyaTimpa item
PATCHUbah sebagianUbah jumlahnya saja
DELETEHapus dataHapus satu item

Kode status

Angka yang dikembalikan server memberi tahu apa yang terjadi. Kamu tidak perlu menghafal semuanya, cukup kelompoknya:

RentangArtiContoh
2xxBerhasil200 OK, 201 Created
3xxPengalihan301 Moved Permanently
4xxKesalahan klien400 Bad Request, 401 Unauthorized, 404 Not Found
5xxKesalahan server500 Internal Server Error, 503 Service Unavailable

Aturan praktis: apa pun yang 400 ke atas adalah kegagalan yang harus kamu tangani. 4xx berarti permintaanmu yang salah; 5xx berarti servernya yang bermasalah.

Menyiapkan backend uji coba

Untuk belajar, Firebase Realtime Database adalah pilihan yang praktis: ia menyediakan REST API otomatis tanpa kamu menulis kode server sama sekali.

  1. Buka console.firebase.google.com, buat proyek baru.
  2. Pilih Realtime DatabaseCreate Database.
  3. Pilih lokasi, lalu pilih Start in test mode.
  4. Salin URL database-nya, bentuknya seperti https://nama-proyek-default-rtdb.asia-southeast1.firebasedatabase.app.

Test mode terbuka untuk siapa saja

Mode uji coba mengizinkan siapa pun membaca dan menulis ke database-mu. Ini tidak apa-apa untuk belajar, tetapi jangan pernah membiarkannya di aplikasi sungguhan. Aturan keamanan yang benar dibahas di Bab 29.

Dengan Firebase Realtime Database, aturan URL-nya sederhana:

GET    https://....app/shopping-list.json        → ambil semua
POST   https://....app/shopping-list.json        → tambah baru
DELETE https://....app/shopping-list/ID.json     → hapus satu

Akhiran .json wajib ada — itulah yang memberi tahu Firebase untuk memakai antarmuka REST.

Memasang paket http

yaml
# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  http: ^1.2.2
bash
flutter pub get

Lalu impor dengan alias, yang merupakan konvensi umum agar jelas dari mana fungsi berasal:

dart
import 'package:http/http.dart' as http;

Mengirim data: POST

dart
import 'dart:convert';
import 'package:http/http.dart' as http;

Future<void> kirimItem(Item item) async {
  final url = Uri.https(
    'nama-proyek-default-rtdb.asia-southeast1.firebasedatabase.app',
    'shopping-list.json',
  );

  final respons = await http.post(
    url,
    headers: {
      'Content-Type': 'application/json',
    },
    body: json.encode({
      'nama': item.nama,
      'jumlah': item.jumlah,
      'kategori': item.kategori.name,
    }),
  );

  print('Status: ${respons.statusCode}');
  print('Body: ${respons.body}');
}

Mari bedah bagiannya.

Uri.https(host, path) membangun URL dengan aman. Kamu bisa juga memakai Uri.parse('https://...'), tetapi Uri.https memisahkan host dan path sehingga lebih sulit salah ketik.

headers memberi tahu server format data yang kamu kirim. 'Content-Type': 'application/json' adalah yang paling umum.

json.encode(...) mengubah Map Dart menjadi string JSON. Tanpa ini kamu mengirim representasi objek Dart yang tidak dimengerti server.

respons.body berisi jawaban server, juga dalam bentuk string.

Firebase membalas dengan ID yang ia buat untuk item barumu:

json
{"name": "-NxYzAbC123def"}

Agar data lokal dan server sinkron, simpan ID itu:

dart
Future<void> _simpanItem() async {
  if (!_kunciForm.currentState!.validate()) return;
  _kunciForm.currentState!.save();

  setState(() => _sedangMengirim = true);

  final url = Uri.https(
    'nama-proyek-default-rtdb.asia-southeast1.firebasedatabase.app',
    'shopping-list.json',
  );

  final respons = await http.post(
    url,
    headers: {'Content-Type': 'application/json'},
    body: json.encode({
      'nama': _namaMasukan,
      'jumlah': _jumlahMasukan,
      'kategori': _kategoriTerpilih.name,
    }),
  );

  final data = json.decode(respons.body) as Map<String, dynamic>;

  // Widget mungkin sudah dilepas selama menunggu
  if (!mounted) return;

  Navigator.of(context).pop(
    Item(
      id: data['name'] as String,   // ID dari server
      nama: _namaMasukan,
      jumlah: _jumlahMasukan,
      kategori: _kategoriTerpilih,
    ),
  );
}

Mengambil data: GET

dart
Future<List<Item>> ambilItem() async {
  final url = Uri.https(
    'nama-proyek-default-rtdb.asia-southeast1.firebasedatabase.app',
    'shopping-list.json',
  );

  final respons = await http.get(url);

  if (respons.statusCode >= 400) {
    throw Exception('Gagal mengambil data. Coba lagi nanti.');
  }

  // Firebase mengembalikan literal "null" kalau tidak ada data
  if (respons.body == 'null') {
    return [];
  }

  final Map<String, dynamic> data = json.decode(respons.body);
  final List<Item> hasil = [];

  for (final entri in data.entries) {
    final kategori = Kategori.values.firstWhere(
      (k) => k.name == entri.value['kategori'],
      orElse: () => Kategori.lainnya,
    );

    hasil.add(
      Item(
        id: entri.key,
        nama: entri.value['nama'] as String,
        jumlah: entri.value['jumlah'] as int,
        kategori: kategori,
      ),
    );
  }

  return hasil;
}

Dua jebakan yang sangat sering ditemui:

Firebase membalas "null" sebagai teks, bukan JSON kosong, ketika tidak ada data. json.decode('null') menghasilkan null, dan kalau kamu langsung memperlakukannya sebagai Map, aplikasi crash. Periksa terlebih dahulu.

firstWhere melempar error kalau tidak menemukan apa-apa. Selalu berikan orElse — kalau data di server ternyata mengandung kategori yang tidak kamu kenal, kamu ingin nilai cadangan, bukan crash.

Struktur data Firebase

Firebase Realtime Database menyimpan data sebagai pohon, bukan tabel. Struktur yang dikembalikan GET terlihat seperti ini:

shopping-list
├── -NxYzAbC123def
│   ├── nama: "Susu"
│   ├── jumlah: 2
│   └── kategori: "susu"
├── -NxYzDefG456hij
│   ├── nama: "Roti"
│   ├── jumlah: 1
│   └── kategori: "roti"
└── -NxYzHijK789klm
    ├── nama: "Apel"
    ├── jumlah: 6
    └── kategori: "buah"

Karena itu kamu menerima Map<String, dynamic> di mana kuncinya adalah ID dan nilainya adalah objek — bukan sebuah list. Itulah alasan kode di atas melakukan iterasi lewat data.entries alih-alih indeks.

Menghapus data: DELETE

dart
Future<void> hapusItem(Item item) async {
  final url = Uri.https(
    'nama-proyek-default-rtdb.asia-southeast1.firebasedatabase.app',
    'shopping-list/${item.id}.json',
  );

  final respons = await http.delete(url);

  if (respons.statusCode >= 400) {
    throw Exception('Gagal menghapus item.');
  }
}

Pola penghapusan yang baik adalah optimistic update: hapus dari tampilan segera agar terasa responsif, lalu kembalikan kalau server menolak.

dart
void _hapusItem(Item item) async {
  final indeks = _daftarItem.indexOf(item);

  // 1. Hapus dari layar segera — terasa instan
  setState(() {
    _daftarItem.remove(item);
  });

  final url = Uri.https(
    'nama-proyek-default-rtdb.asia-southeast1.firebasedatabase.app',
    'shopping-list/${item.id}.json',
  );

  final respons = await http.delete(url);

  if (!mounted) return;

  // 2. Kalau gagal, kembalikan ke posisi semula
  if (respons.statusCode >= 400) {
    setState(() {
      _daftarItem.insert(indeks, item);
    });

    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Gagal menghapus. Coba lagi.')),
    );
  }
}

Mengelola tiga keadaan

Setiap layar yang memuat data dari jaringan punya tiga keadaan yang harus ditangani: sedang memuat, gagal, dan berhasil — dan keadaan berhasil masih terbagi menjadi ada data dan kosong.

Berikut implementasi lengkapnya.

dart
// lib/screens/daftar_belanja.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../models/item.dart';
import '../models/kategori.dart';
import 'tambah_item.dart';

class DaftarBelanja extends StatefulWidget {
  const DaftarBelanja({super.key});

  @override
  State<DaftarBelanja> createState() => _DaftarBelanjaState();
}

class _DaftarBelanjaState extends State<DaftarBelanja> {
  List<Item> _daftarItem = [];
  var _sedangMemuat = true;
  String? _pesanKesalahan;

  @override
  void initState() {
    super.initState();
    _muatItem();
  }

  Future<void> _muatItem() async {
    final url = Uri.https(
      'nama-proyek-default-rtdb.asia-southeast1.firebasedatabase.app',
      'shopping-list.json',
    );

    try {
      final respons = await http.get(url);

      if (respons.statusCode >= 400) {
        setState(() {
          _pesanKesalahan = 'Gagal mengambil data. Silakan coba lagi.';
          _sedangMemuat = false;
        });
        return;
      }

      if (respons.body == 'null') {
        setState(() => _sedangMemuat = false);
        return;
      }

      final Map<String, dynamic> data = json.decode(respons.body);
      final List<Item> dimuat = [];

      for (final entri in data.entries) {
        final kategori = Kategori.values.firstWhere(
          (k) => k.name == entri.value['kategori'],
          orElse: () => Kategori.lainnya,
        );

        dimuat.add(
          Item(
            id: entri.key,
            nama: entri.value['nama'] as String,
            jumlah: entri.value['jumlah'] as int,
            kategori: kategori,
          ),
        );
      }

      setState(() {
        _daftarItem = dimuat;
        _sedangMemuat = false;
      });
    } catch (kesalahan) {
      setState(() {
        _pesanKesalahan = 'Terjadi kesalahan. Periksa koneksi internetmu.';
        _sedangMemuat = false;
      });
    }
  }

  void _tambahItem() async {
    final itemBaru = await Navigator.of(context).push<Item>(
      MaterialPageRoute(builder: (ctx) => const TambahItem()),
    );

    if (itemBaru == null) return;

    setState(() {
      _daftarItem.add(itemBaru);
    });
  }

  void _hapusItem(Item item) async {
    final indeks = _daftarItem.indexOf(item);

    setState(() {
      _daftarItem.remove(item);
    });

    final url = Uri.https(
      'nama-proyek-default-rtdb.asia-southeast1.firebasedatabase.app',
      'shopping-list/${item.id}.json',
    );

    final respons = await http.delete(url);

    if (!mounted) return;

    if (respons.statusCode >= 400) {
      setState(() {
        _daftarItem.insert(indeks, item);
      });
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Gagal menghapus item.')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    // --- Tentukan isi berdasarkan keadaan ---
    Widget isi = const Center(
      child: Text('Belum ada item. Tambahkan sesuatu!'),
    );

    if (_sedangMemuat) {
      isi = const Center(child: CircularProgressIndicator());
    } else if (_pesanKesalahan != null) {
      isi = Center(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const Icon(Icons.cloud_off, size: 48),
              const SizedBox(height: 16),
              Text(_pesanKesalahan!, textAlign: TextAlign.center),
              const SizedBox(height: 16),
              ElevatedButton.icon(
                onPressed: () {
                  setState(() {
                    _sedangMemuat = true;
                    _pesanKesalahan = null;
                  });
                  _muatItem();
                },
                icon: const Icon(Icons.refresh),
                label: const Text('Coba Lagi'),
              ),
            ],
          ),
        ),
      );
    } else if (_daftarItem.isNotEmpty) {
      isi = RefreshIndicator(
        onRefresh: _muatItem,
        child: ListView.builder(
          itemCount: _daftarItem.length,
          itemBuilder: (ctx, indeks) => Dismissible(
            key: ValueKey(_daftarItem[indeks].id),
            background: Container(
              color: Theme.of(context).colorScheme.error,
              alignment: Alignment.centerRight,
              padding: const EdgeInsets.only(right: 20),
              child: const Icon(Icons.delete, color: Colors.white),
            ),
            direction: DismissDirection.endToStart,
            onDismissed: (_) => _hapusItem(_daftarItem[indeks]),
            child: ListTile(
              title: Text(_daftarItem[indeks].nama),
              leading: Container(
                width: 24,
                height: 24,
                decoration: BoxDecoration(
                  color: _daftarItem[indeks].kategori.warna,
                  shape: BoxShape.circle,
                ),
              ),
              trailing: Text(_daftarItem[indeks].jumlah.toString()),
            ),
          ),
        ),
      );
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('Daftar Belanja'),
        actions: [
          IconButton(
            onPressed: _tambahItem,
            icon: const Icon(Icons.add),
          ),
        ],
      ),
      body: isi,
    );
  }
}

Perhatikan struktur build(): sebuah variabel isi diinisialisasi dengan keadaan "kosong", lalu ditimpa oleh cabang-cabang yang lebih spesifik. Ini pola yang bersih dan mudah diperluas.

FutureBuilder: alternatif yang lebih ringkas

Pola tiga keadaan di atas cukup banyak kode boilerplate. FutureBuilder menyederhanakannya dengan mengelola keadaan itu untukmu.

dart
class DaftarBelanjaRingkas extends StatefulWidget {
  const DaftarBelanjaRingkas({super.key});

  @override
  State<DaftarBelanjaRingkas> createState() => _DaftarBelanjaRingkasState();
}

class _DaftarBelanjaRingkasState extends State<DaftarBelanjaRingkas> {
  late Future<List<Item>> _futureItem;

  @override
  void initState() {
    super.initState();
    _futureItem = _muatItem();   // ⚠️ dipanggil SEKALI, di initState
  }

  Future<List<Item>> _muatItem() async {
    final url = Uri.https(
      'nama-proyek-default-rtdb.asia-southeast1.firebasedatabase.app',
      'shopping-list.json',
    );

    final respons = await http.get(url);

    if (respons.statusCode >= 400) {
      throw Exception('Gagal mengambil data. Coba lagi nanti.');
    }

    if (respons.body == 'null') return [];

    final Map<String, dynamic> data = json.decode(respons.body);

    return data.entries.map((entri) {
      return Item(
        id: entri.key,
        nama: entri.value['nama'] as String,
        jumlah: entri.value['jumlah'] as int,
        kategori: Kategori.values.firstWhere(
          (k) => k.name == entri.value['kategori'],
          orElse: () => Kategori.lainnya,
        ),
      );
    }).toList();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Daftar Belanja')),
      body: FutureBuilder<List<Item>>(
        future: _futureItem,
        builder: (context, snapshot) {
          // Keadaan 1: sedang menunggu
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }

          // Keadaan 2: gagal
          if (snapshot.hasError) {
            return Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  const Icon(Icons.error_outline, size: 48),
                  const SizedBox(height: 12),
                  Text('${snapshot.error}'),
                  const SizedBox(height: 12),
                  ElevatedButton(
                    onPressed: () {
                      setState(() {
                        _futureItem = _muatItem();   // buat Future baru
                      });
                    },
                    child: const Text('Coba Lagi'),
                  ),
                ],
              ),
            );
          }

          final daftar = snapshot.data ?? [];

          // Keadaan 3: berhasil tapi kosong
          if (daftar.isEmpty) {
            return const Center(child: Text('Belum ada item.'));
          }

          // Keadaan 4: berhasil dengan data
          return ListView.builder(
            itemCount: daftar.length,
            itemBuilder: (ctx, i) => ListTile(
              title: Text(daftar[i].nama),
              trailing: Text('${daftar[i].jumlah}'),
            ),
          );
        },
      ),
    );
  }
}

Jangan panggil fungsi async langsung di future:

Ini kesalahan yang sangat umum dan menyebabkan permintaan jaringan berulang tanpa henti:

dart
// ❌ SALAH — _muatItem() dipanggil lagi setiap build,
//     dan setiap panggilan memicu build lagi. Loop tak berujung.
FutureBuilder(
  future: _muatItem(),
  builder: ...,
)

// ✅ BENAR — Future dibuat sekali di initState
late Future<List<Item>> _futureItem;

@override
void initState() {
  super.initState();
  _futureItem = _muatItem();
}

FutureBuilder(
  future: _futureItem,
  builder: ...,
)

Model dengan fromJson dan toJson

Menulis logika penguraian di dalam widget menjadi berantakan dengan cepat. Pindahkan ke modelnya.

dart
// lib/models/item.dart
import 'kategori.dart';

class Item {
  const Item({
    required this.id,
    required this.nama,
    required this.jumlah,
    required this.kategori,
  });

  final String id;
  final String nama;
  final int jumlah;
  final Kategori kategori;

  /// Membuat Item dari satu entri JSON Firebase.
  /// ID datang terpisah karena ia adalah kunci, bukan bagian dari nilai.
  factory Item.dariJson(String id, Map<String, dynamic> json) {
    return Item(
      id: id,
      nama: json['nama'] as String,
      jumlah: (json['jumlah'] as num).toInt(),
      kategori: Kategori.values.firstWhere(
        (k) => k.name == json['kategori'],
        orElse: () => Kategori.lainnya,
      ),
    );
  }

  /// Mengubah Item menjadi JSON untuk dikirim ke server.
  /// ID tidak disertakan karena server yang mengelolanya.
  Map<String, dynamic> toJson() => {
        'nama': nama,
        'jumlah': jumlah,
        'kategori': kategori.name,
      };

  /// Membuat salinan dengan beberapa nilai diubah.
  Item copyWith({
    String? id,
    String? nama,
    int? jumlah,
    Kategori? kategori,
  }) {
    return Item(
      id: id ?? this.id,
      nama: nama ?? this.nama,
      jumlah: jumlah ?? this.jumlah,
      kategori: kategori ?? this.kategori,
    );
  }
}

Sekarang penguraian menjadi satu baris:

dart
final daftar = data.entries
    .map((e) => Item.dariJson(e.key, e.value as Map<String, dynamic>))
    .toList();

Perhatikan (json['jumlah'] as num).toInt(). JSON bisa mengirim 2 atau 2.0 untuk nilai yang sama; melewati num dulu membuat penguraian tahan terhadap keduanya. Ini jebakan yang dibahas di Bab 2 dan layak diingat.

copyWith akan menjadi sangat penting di Bagian III, tempat state harus berupa objek baru agar perubahannya terdeteksi.

Memisahkan lapisan layanan

Menaruh kode HTTP di dalam widget membuatnya sulit diuji dan sulit dipakai ulang. Pisahkan menjadi kelas layanan.

dart
// lib/services/layanan_belanja.dart
import 'dart:convert';
import 'dart:io';
import 'dart:async';
import 'package:http/http.dart' as http;
import '../models/item.dart';

class KesalahanJaringan implements Exception {
  KesalahanJaringan(this.pesan);
  final String pesan;

  @override
  String toString() => pesan;
}

class LayananBelanja {
  LayananBelanja({http.Client? klien}) : _klien = klien ?? http.Client();

  final http.Client _klien;

  static const _host =
      'nama-proyek-default-rtdb.asia-southeast1.firebasedatabase.app';
  static const _batasWaktu = Duration(seconds: 10);

  Uri _url(String path) => Uri.https(_host, path);

  Future<List<Item>> ambilSemua() async {
    try {
      final respons =
          await _klien.get(_url('shopping-list.json')).timeout(_batasWaktu);

      if (respons.statusCode >= 400) {
        throw KesalahanJaringan(
          'Server menolak permintaan (${respons.statusCode}).',
        );
      }

      if (respons.body == 'null') return [];

      final data = json.decode(respons.body) as Map<String, dynamic>;

      return data.entries
          .map((e) => Item.dariJson(e.key, e.value as Map<String, dynamic>))
          .toList();
    } on SocketException {
      throw KesalahanJaringan('Tidak ada koneksi internet.');
    } on TimeoutException {
      throw KesalahanJaringan('Server terlalu lama merespons.');
    } on FormatException {
      throw KesalahanJaringan('Format data dari server tidak dikenali.');
    }
  }

  Future<Item> tambah(Item item) async {
    try {
      final respons = await _klien
          .post(
            _url('shopping-list.json'),
            headers: {'Content-Type': 'application/json'},
            body: json.encode(item.toJson()),
          )
          .timeout(_batasWaktu);

      if (respons.statusCode >= 400) {
        throw KesalahanJaringan('Gagal menyimpan item.');
      }

      final data = json.decode(respons.body) as Map<String, dynamic>;
      return item.copyWith(id: data['name'] as String);
    } on SocketException {
      throw KesalahanJaringan('Tidak ada koneksi internet.');
    } on TimeoutException {
      throw KesalahanJaringan('Server terlalu lama merespons.');
    }
  }

  Future<void> hapus(String id) async {
    try {
      final respons = await _klien
          .delete(_url('shopping-list/$id.json'))
          .timeout(_batasWaktu);

      if (respons.statusCode >= 400) {
        throw KesalahanJaringan('Gagal menghapus item.');
      }
    } on SocketException {
      throw KesalahanJaringan('Tidak ada koneksi internet.');
    } on TimeoutException {
      throw KesalahanJaringan('Server terlalu lama merespons.');
    }
  }

  void tutup() => _klien.close();
}

Beberapa keputusan desain yang layak diperhatikan:

http.Client disuntikkan lewat konstruktor. Ini memungkinkan kamu mengganti klien asli dengan klien palsu saat pengujian, tanpa benar-benar memanggil jaringan.

.timeout() pada setiap permintaan. Tanpa ini, sebuah permintaan bisa menggantung selamanya ketika jaringan bermasalah, dan aplikasimu tampak macet.

Exception khusus KesalahanJaringan. Ini memisahkan kegagalan yang kamu harapkan dari bug sungguhan, dan pesannya sudah siap ditampilkan ke pengguna.

Pemakaiannya di widget menjadi jauh lebih bersih:

dart
final _layanan = LayananBelanja();

Future<void> _muat() async {
  try {
    final daftar = await _layanan.ambilSemua();
    if (!mounted) return;
    setState(() {
      _daftarItem = daftar;
      _sedangMemuat = false;
    });
  } on KesalahanJaringan catch (e) {
    if (!mounted) return;
    setState(() {
      _pesanKesalahan = e.pesan;
      _sedangMemuat = false;
    });
  }
}

Struktur folder

lib/
├── main.dart
├── models/
│   ├── item.dart              ← fromJson, toJson, copyWith
│   └── kategori.dart
├── services/
│   └── layanan_belanja.dart   ← seluruh kode HTTP
├── screens/
│   ├── daftar_belanja.dart
│   └── tambah_item.dart
└── widgets/
    └── kartu_item.dart

Pemisahan ini akan menjadi fondasi arsitektur yang lebih formal di Bab 22 dan Bab 33.

Latihan Mandiri

Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.

Variasi 1: Empat Keadaan — ⭐⭐ · 45–60 menit

Tantangan: Bangun layar yang menampilkan daftar dari satu API publik gratis pilihanmu — negara, gempa bumi, kutipan, apa saja. Yang diuji di sini bukan kemampuan menampilkan data, melainkan kemampuan menangani semua yang bisa salah.

Bayangan tampilan (empat keadaan):

text
  Memuat            Gagal              Kosong            Ada data
╭─────────╮       ╭─────────╮        ╭─────────╮       ╭─────────╮
│    ⏳    │       │    ⚠     │        │   📭     │       │ Item 1  │
│ Memuat.. │       │ Gagal    │        │ Belum ada│       │ Item 2  │
│          │       │ memuat.  │        │ data     │       │ Item 3  │
│          │       │[Coba Lagi]│       │          │       │         │
╰─────────╯       ╰─────────╯        ╰─────────╯       ╰─────────╯

Kriteria selesai:

  • Keempat keadaan punya tampilannya sendiri: sedang memuat, gagal, kosong, dan ada data. Uji keadaan gagal dengan mematikan koneksi, dan keadaan kosong dengan sengaja mengubah URL ke endpoint yang hasilnya kosong.
  • Seluruh kode HTTP berada di kelas layanan terpisah, dan model punya fromJson yang melewati as num sebelum konversi angka.
  • Permintaan memakai .timeout(), dan SocketException serta TimeoutException diubah menjadi kalimat berbahasa Indonesia yang menyarankan tindakan.
  • Ada tombol coba lagi pada tampilan gagal yang benar-benar mengulang permintaan tanpa perlu keluar dari layar.

Petunjuk: Kalau kamu memilih FutureBuilder, ada satu jebakan yang akan menghabiskan waktumu: Future-nya harus dibuat di initState dan disimpan ke variabel. Membuatnya langsung di dalam build() berarti setiap rebuild memicu permintaan baru, yang memicu rebuild, dan seterusnya — aplikasi terlihat seperti sedang memuat selamanya sambil membakar kuota. Untuk membedakan "kosong" dari "gagal", ingat bahwa respons 200 dengan array kosong adalah keberhasilan, bukan kegagalan; keduanya butuh cabang yang berbeda. Dan periksa kode status sebelum mengurai body — mengurai halaman error HTML sebagai JSON menghasilkan pesan yang membingungkan.

Variasi 2: Pencarian Langsung — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun kolom pencarian yang memanggil API setiap kali pengguna berhenti mengetik sejenak. Hasil yang datang terlambat dari kata kunci lama tidak boleh menimpa hasil kata kunci terbaru.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  🔍 [flutter          ] ⏳          │
│  ┌─────────────────────────────┐  │
│  │ Flutter - Google's UI Kit    │  │
│  │ FlutterFire                  │  │
│  │ Flutter Riverpod              │  │
│  └─────────────────────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Ketik "f-l-u-t-t-e-r" cepat huruf demi huruf → hanya satu request yang terkirim (bukan tujuh), dan hasil dari kata kunci "flu" yang telat datang tidak menimpa hasil "flutter" yang lebih baru.

Kriteria selesai:

  • Mengetik "flutter" huruf demi huruf menghasilkan satu permintaan, bukan tujuh.
  • Hasil dari kata kunci yang sudah tidak berlaku diabaikan, meskipun ia tiba belakangan.
  • Mengosongkan kolom membatalkan permintaan yang sedang berjalan dan mengembalikan tampilan awal.
  • Indikator memuat muncul di dalam kolom pencarian, bukan menutupi seluruh daftar hasil.

Petunjuk: Kriteria kedua adalah masalah yang sering luput sampai jaringan melambat: permintaan untuk "flu" bisa saja tiba setelah permintaan untuk "flutter", dan hasilnya akan menimpa yang benar. Solusinya adalah menandai setiap permintaan dengan kata kunci atau nomor urutnya, lalu membuang hasil yang penandanya sudah bukan yang terkini. Timer yang dibatalkan dan dibuat ulang di setiap ketukan tombol menyelesaikan bagian penundaannya.

Variasi 3: Perubahan Optimistis — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun layar daftar item yang mendukung tambah dan hapus lewat API, dengan perubahan optimistis: perubahan tampil seketika di layar, lalu dibatalkan dan dikembalikan kalau server menolak.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  ┌─────────────────────────────┐  │
│  │ Item A                       │  │
│  │ Item B (mengirim...) ⏳      │  │
│  │ Item C                       │  │
│  └─────────────────────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Hapus "Item A" → langsung hilang dari layar seketika. Kalau server menolak penghapusan, "Item A" muncul lagi tepat di posisi semula disertai pesan error — bukan nyelip di akhir daftar.

Kriteria selesai:

  • Menghapus item menghilangkannya seketika, dan mengembalikannya ke posisi semula kalau server gagal — disertai pesan yang menjelaskan.
  • Menambah item menampilkannya seketika dengan penanda "sedang dikirim", lalu penandanya hilang ketika berhasil.
  • Dua penghapusan cepat berturut-turut ditangani dengan benar, termasuk ketika hanya salah satunya gagal.
  • Kode status di atas 400 selalu diperiksa sebelum badan respons diurai.

Petunjuk: Perubahan optimistis membuat aplikasi terasa jauh lebih responsif, tetapi ia memindahkan beban ke penanganan kegagalan — dan itulah yang dilatih di sini. Simpan salinan keadaan sebelum perubahan, dan pulihkan dari salinan itu saat gagal. Untuk dua penghapusan bersamaan, pulihkan berdasarkan identitas item, bukan berdasarkan indeks, karena indeks sudah bergeser saat kegagalan diketahui.

Variasi 4: Lapisan Layanan — ⭐⭐⭐ · 45–60 menit

Tantangan: Ambil layar mana pun yang sudah memanggil API secara langsung dari widget, lalu refaktor menjadi tiga bagian: kelas model dengan fromJson, toJson, dan copyWith; kelas layanan yang memegang seluruh kode HTTP; dan widget yang tidak tahu apa pun tentang HTTP.

Kriteria selesai:

  • Widget tidak mengimpor package:http/http.dart maupun dart:convert.
  • Kelas layanan bisa dipakai dari program Dart biasa tanpa Flutter sama sekali — buktikan dengan menjalankannya lewat dart run.
  • Model menangani field yang hilang dan angka bertipe int untuk double.
  • Mengganti URL dasar API cukup diubah di satu tempat.

Petunjuk: Kriteria kedua adalah ujian yang jujur dan cepat: kalau lapisan layananmu bisa dijalankan tanpa Flutter, berarti ia benar-benar bersih dari urusan tampilan. Kalau tidak bisa, cari apa yang menyeretnya — biasanya BuildContext yang dioper masuk, atau SnackBar yang ditampilkan dari dalam sana. Keduanya adalah tanda tanggung jawab yang tertukar, dan pemisahan ini adalah pemanasan untuk seluruh Bagian III.

Variasi 5: Ketahanan Jaringan — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Bangun klien API yang tahan terhadap jaringan buruk: batas waktu per permintaan, percobaan ulang otomatis dengan jeda yang membesar untuk kegagalan sementara, dan tanpa percobaan ulang untuk kegagalan yang memang permanen.

Kriteria selesai:

  • Kegagalan 500 dan 503 dicoba ulang; kegagalan 400, 401, dan 404 tidak.
  • Jeda antar percobaan membesar, dan ada batas jumlah percobaan.
  • Setiap jenis kegagalan menghasilkan pesan berbeda yang bisa ditindaklanjuti pengguna.
  • Kamu sudah mengujinya dengan mematikan jaringan di tengah permintaan, dan dengan endpoint yang sengaja mengembalikan 401.

Petunjuk: Membedakan kegagalan yang layak dicoba ulang dari yang tidak adalah inti latihan ini: mencoba ulang permintaan yang ditolak karena kredensial salah hanya membuang waktu dan bisa memicu pemblokiran. Aturan praktisnya, 5xx dan kegagalan jaringan bersifat sementara, sementara 4xx berarti permintaanmu sendiri yang bermasalah — kecuali 429, yang justru harus dicoba ulang setelah menunggu.

Ikhtisar

  • HTTP bekerja lewat pasangan permintaan dan respons. Metode GET, POST, PUT, PATCH, DELETE menyatakan maksudmu.
  • Kode status 400 ke atas berarti gagal: 4xx kesalahan klien, 5xx kesalahan server. Selalu periksa sebelum mengurai body.
  • json.encode untuk mengirim, json.decode untuk membaca. Sertakan header Content-Type: application/json pada POST.
  • Firebase Realtime Database mengembalikan objek dengan ID sebagai kunci — bukan list — dan mengembalikan teks "null" ketika kosong.
  • Selalu beri orElse pada firstWhere, atau ia akan melempar error untuk data yang tidak dikenal.
  • Optimistic update — hapus dari layar dulu, kembalikan kalau server gagal — membuat aplikasi terasa jauh lebih responsif.
  • Tangani empat keadaan: memuat, gagal, kosong, dan ada data.
  • FutureBuilder menyederhanakan pola itu, tetapi Future harus dibuat di initState, bukan di dalam build() — kalau tidak, terjadi loop permintaan tak berujung.
  • Pindahkan fromJson/toJson/copyWith ke kelas model, dan seluruh kode HTTP ke kelas layanan.
  • Lewati as num sebelum .toInt()/.toDouble() saat mengurai JSON.
  • Selalu pasang .timeout(), dan ubah SocketException/TimeoutException menjadi pesan yang bisa dimengerti pengguna.
  • Selalu periksa mounted setelah await sebelum menyentuh setState atau context.

Berikutnya: Bab 15 — Penyimpanan Lokal, agar data tetap ada meskipun tidak ada internet.

Transkrip asli

Disintesis dari 1_flutter_core/11_shopping-list_connecting-bacend.md (16 video) dan 1_flutter_zero-to-hero/11_networking-api.md (Get, Class, FutureBuilder). Lihat PDF Flutter Core dan PDF Zero to Hero.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.