Tampilan
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
| Metode | Maksud | Contoh |
|---|---|---|
GET | Ambil data | Muat daftar belanja |
POST | Buat data baru | Tambah item baru |
PUT | Ganti data seluruhnya | Timpa item |
PATCH | Ubah sebagian | Ubah jumlahnya saja |
DELETE | Hapus data | Hapus satu item |
Kode status
Angka yang dikembalikan server memberi tahu apa yang terjadi. Kamu tidak perlu menghafal semuanya, cukup kelompoknya:
| Rentang | Arti | Contoh |
|---|---|---|
| 2xx | Berhasil | 200 OK, 201 Created |
| 3xx | Pengalihan | 301 Moved Permanently |
| 4xx | Kesalahan klien | 400 Bad Request, 401 Unauthorized, 404 Not Found |
| 5xx | Kesalahan server | 500 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.
- Buka console.firebase.google.com, buat proyek baru.
- Pilih Realtime Database → Create Database.
- Pilih lokasi, lalu pilih Start in test mode.
- 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 satuAkhiran .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.2bash
flutter pub getLalu 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.dartPemisahan 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
fromJsonyang melewatias numsebelum konversi angka. - Permintaan memakai
.timeout(), danSocketExceptionsertaTimeoutExceptiondiubah 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.dartmaupundart: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
intuntukdouble. - 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,DELETEmenyatakan maksudmu. - Kode status 400 ke atas berarti gagal:
4xxkesalahan klien,5xxkesalahan server. Selalu periksa sebelum mengurai body. json.encodeuntuk mengirim,json.decodeuntuk membaca. Sertakan headerContent-Type: application/jsonpadaPOST.- Firebase Realtime Database mengembalikan objek dengan ID sebagai kunci — bukan list — dan mengembalikan teks
"null"ketika kosong. - Selalu beri
orElsepadafirstWhere, 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.
FutureBuildermenyederhanakan pola itu, tetapiFutureharus dibuat diinitState, bukan di dalambuild()— kalau tidak, terjadi loop permintaan tak berujung.- Pindahkan
fromJson/toJson/copyWithke kelas model, dan seluruh kode HTTP ke kelas layanan. - Lewati
as numsebelum.toInt()/.toDouble()saat mengurai JSON. - Selalu pasang
.timeout(), dan ubahSocketException/TimeoutExceptionmenjadi pesan yang bisa dimengerti pengguna. - Selalu periksa
mountedsetelahawaitsebelum menyentuhsetStateataucontext.
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.