Implementasi

Membangun proyek akhir dengan menggabungkan state, navigasi, dan integrasi API.

Daftar isi

Cetak biru dari topik desain kini diturunkan menjadi kode. Topik ini membangun aplikasi catatan dari proyek kosong sampai seluruh lapisannya tersambung, yaitu model, service jaringan, dua kelas state, dan layar-layarnya, dengan urutan dari lapisan yang tidak bergantung pada apa pun menuju lapisan paling atas. Setiap berkas utama ditampilkan utuh, sehingga hasilnya dapat disusun kembali berkas demi berkas tanpa menebak bagian yang terlewat.

Membuat proyek dan menyusun direktori#

Proyek dibuat dengan satu perintah, dan keempat package yang dibutuhkan langsung ditambahkan sekaligus agar penyuntingan pubspec.yaml tidak perlu diulang.

bash
# tidak-dijalankan
# membuat proyek Flutter bernama aplikasi_catatan
flutter create aplikasi_catatan
cd aplikasi_catatan
# menambahkan keempat package yang dibutuhkan
flutter pub add provider go_router http shared_preferences

Susunan direktori di dalam lib mengikuti lapisan yang dirancang pada topik desain, sehingga letak setiap berkas sudah tidak perlu dipikirkan saat kodenya ditulis.

text
lib/
├── models/
│   └── catatan.dart
├── services/
│   ├── catatan_service.dart
│   ├── sesi_store.dart
│   └── catatan_store.dart
├── screens/
│   ├── layar_masuk.dart
│   ├── layar_daftar.dart
│   └── layar_ubah.dart
└── widgets/
    └── kartu_catatan.dart

Direktori models menampung kelas data murni tanpa dependensi Flutter, services menampung komunikasi jaringan dan kelas state, screens memuat satu berkas untuk setiap layar, dan widgets menyimpan komponen kecil yang digunakan ulang seperti kartu satu catatan. Berkas main.dart tetap berada di akar proyek sebagai titik masuknya. Pembagian ini membuat arah impor selalu mengalir dari layar menuju services lalu models, dan tidak pernah berlawanan arah.

pubspec.yaml sebagai daftar dependensi#

yaml pubspec.yaml
name: aplikasi_catatan
description: Aplikasi catatan untuk latihan jalur Flutter dengan simpan lokal dan sinkronisasi API.
publish_to: none
version: 1.0.0+1

environment:
  sdk: ^3.13.0

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.1.5
  go_router: ^18.1.3
  http: ^1.6.0
  shared_preferences: ^2.5.6

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^6.0.0

flutter:
  uses-material-design: true

Nilai name wajib sama dengan awalan impor package:aplikasi_catatan/... pada berkas lainnya, sehingga penggantian nama proyek berarti mengubah berkas ini beserta seluruh pernyataan impornya. Batas sdk: ^3.13.0 mengikuti Dart yang menyertai Flutter 3.47. Empat dependensi masing-masing melayani satu kebutuhan yang sudah dirancang, yaitu Provider untuk state, go_router untuk navigasi, http untuk API, dan shared_preferences untuk simpan lokal. Kelompok dev_dependencies hanya digunakan saat pengembangan, yaitu flutter_test untuk pengujian dan flutter_lints untuk analisis statis.

Model Catatan#

dart lib/models/catatan.dart
// tidak-dijalankan
// Model satu catatan: dipakai bersama oleh lapisan jaringan dan penyimpanan lokal.
class Catatan {
  const Catatan({required this.id, required this.judul, required this.isi});

  final String id;
  final String judul;
  final String isi;

  // Membentuk objek dari Map hasil jsonDecode pada respons API.
  factory Catatan.fromJson(Map<String, dynamic> json) {
    return Catatan(
      id: json['id'] as String,
      judul: json['judul'] as String,
      isi: json['isi'] as String,
    );
  }

  // Menyiapkan Map untuk jsonEncode sebelum dikirim ke API atau disimpan lokal.
  Map<String, dynamic> toJson() => {'id': id, 'judul': judul, 'isi': isi};
}

Model ini mengikuti pola fromJson dan toJson dari pembahasan JSON, dan lingkupnya persis sama dengan kebutuhan aplikasi, yaitu tiga kolom bertipe String. Seluruh field dinyatakan final dengan konstruktor const, sehingga objek Catatan tidak dapat diubah setelah dibentuk dan perubahan selalu berupa objek baru, sejalan dengan pola immutability dari modul Provider. Berkas ini tidak mengimpor package mana pun, sehingga pengujian unitnya berjalan cepat tanpa perangkat.

CatatanService: satu pintu menuju API#

dart lib/services/catatan_service.dart
// tidak-dijalankan
// Lapisan jaringan: seluruh komunikasi dengan API catatan berada di sini.
import 'dart:convert';

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

import '../models/catatan.dart';

class CatatanService {
  CatatanService({required this.baseUrl, http.Client? klien})
      : _klien = klien ?? http.Client();

  final String baseUrl;
  final http.Client _klien;

  Map<String, String> _header(String token) => {
        'Authorization': 'Bearer $token',
        'Content-Type': 'application/json',
      };

  // Memeriksa nama pengguna dan sandi ke API, lalu mengembalikan token sesi.
  Future<String> masuk(String namaPengguna, String sandi) async {
    final respons = await _klien.post(
      Uri.parse('$baseUrl/masuk'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'namaPengguna': namaPengguna, 'sandi': sandi}),
    );
    if (respons.statusCode != 200) {
      throw Exception('Autentikasi gagal, kode ${respons.statusCode}');
    }
    final Map<String, dynamic> isi =
        jsonDecode(respons.body) as Map<String, dynamic>;
    return isi['token'] as String;
  }

  // Mengambil seluruh catatan milik pengguna yang sedang masuk.
  Future<List<Catatan>> ambilSemua(String token) async {
    final respons = await _klien.get(
      Uri.parse('$baseUrl/catatan'),
      headers: _header(token),
    );
    if (respons.statusCode != 200) {
      throw Exception('Gagal memuat catatan, kode ${respons.statusCode}');
    }
    final List<dynamic> data = jsonDecode(respons.body) as List<dynamic>;
    return data
        .map((elemen) => Catatan.fromJson(elemen as Map<String, dynamic>))
        .toList();
  }

  // Mengirim satu catatan ke API dan mengembalikan versi yang tersimpan.
  Future<Catatan> kirim(Catatan catatan, String token) async {
    final respons = await _klien.post(
      Uri.parse('$baseUrl/catatan'),
      headers: _header(token),
      body: jsonEncode(catatan.toJson()),
    );
    if (respons.statusCode != 201) {
      throw Exception('Gagal mengirim catatan, kode ${respons.statusCode}');
    }
    return Catatan.fromJson(jsonDecode(respons.body) as Map<String, dynamic>);
  }
}

Seluruh URL, header, dan pemeriksaan statusCode kini berada pada satu kelas, sehingga layar tidak pernah mengirim permintaan secara langsung. Method masuk mengirim nama pengguna dan sandi ke endpoint /masuk lalu mengembalikan token, sedangkan ambilSemua dan kirim membawa token tersebut pada header Authorization. Autentikasi sengaja ditempatkan di service yang sama demi menjaga lingkup tetap kecil, dan aplikasi yang lebih besar sebaiknya memisahkannya ke service tersendiri. Konstruktor menerima parameter klien bertipe http.Client yang secara bawaan berupa klien nyata, sehingga pengujian dapat menyuntikkan klien tiruan (mock), yaitu objek pengganti yang jawabannya disiapkan lebih dahulu.

CatatanStore: state, simpan lokal, dan sinkronisasi#

dart lib/services/catatan_store.dart
// tidak-dijalankan
// Lingkup state isi: daftar catatan, simpan lokal, dan sinkronisasi ke API.
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';

import '../models/catatan.dart';
import 'catatan_service.dart';

class CatatanStore extends ChangeNotifier {
  CatatanStore({this.service});

  static const _kunciLokal = 'daftar-catatan';

  // Service bernilai null pada pengujian, sehingga tidak ada akses jaringan.
  final CatatanService? service;

  final List<Catatan> _catatan = <Catatan>[];

  List<Catatan> get catatan => List.unmodifiable(_catatan);

  Catatan? cari(String id) {
    for (final c in _catatan) {
      if (c.id == id) {
        return c;
      }
    }
    return null;
  }

  // Satu pintu perubahan daftar: mengganti isi lalu memberi tahu pendengar.
  void terima(List<Catatan> daftarBaru) {
    _catatan
      ..clear()
      ..addAll(daftarBaru);
    notifyListeners();
  }

  // Memuat data: lokal terlebih dahulu, lalu API bila service tersedia.
  Future<void> muat({String? token}) async {
    final preferensi = await SharedPreferences.getInstance();
    final tersimpan = preferensi.getString(_kunciLokal);
    if (tersimpan != null) {
      terima(_urai(tersimpan));
    }
    final jaringan = service;
    if (jaringan == null || token == null) {
      return;
    }
    try {
      terima(await jaringan.ambilSemua(token));
      await simpanLokal();
    } catch (error) {
      // Jaringan gagal: data lokal yang sudah tampil tetap dipakai.
      debugPrint('Sinkronisasi dilewati: $error');
    }
  }

  Future<void> tambah(Catatan catatan, {String? token}) async {
    terima([..._catatan, catatan]);
    await _simpanDanKirim(catatan, token);
  }

  Future<void> ubah(Catatan catatan, {String? token}) async {
    final indeks = _catatan.indexWhere((c) => c.id == catatan.id);
    if (indeks == -1) {
      return;
    }
    final salinan = [..._catatan];
    salinan[indeks] = catatan;
    terima(salinan);
    await _simpanDanKirim(catatan, token);
  }

  // Menulis seluruh daftar ke shared_preferences sebagai satu string JSON.
  Future<void> simpanLokal() async {
    final preferensi = await SharedPreferences.getInstance();
    await preferensi.setString(
      _kunciLokal,
      jsonEncode(_catatan.map((c) => c.toJson()).toList()),
    );
  }

  Future<void> _simpanDanKirim(Catatan catatan, String? token) async {
    await simpanLokal();
    final jaringan = service;
    if (jaringan == null || token == null) {
      return;
    }
    try {
      await jaringan.kirim(catatan, token);
    } catch (error) {
      // Pengiriman gagal: data tetap aman di penyimpanan lokal.
      debugPrint('Pengiriman ditunda: $error');
    }
  }

  List<Catatan> _urai(String json) {
    final List<dynamic> data = jsonDecode(json) as List<dynamic>;
    return data
        .map((elemen) => Catatan.fromJson(elemen as Map<String, dynamic>))
        .toList();
  }
}

Kelas ini memuat daftar catatan beserta seluruh aturan perubahannya, dan widget tidak pernah menyunting daftar secara langsung karena satu-satunya pintu adalah method yang selalu diakhiri notifyListeners(). Urutan pemuatan mengikuti keputusan desain, yaitu penyimpanan lokal terlebih dahulu, kemudian API. Parameter service sengaja dibiarkan nullable, karena pengujian membuat store tanpa service sehingga seluruh jalur jaringannya pasti terlewati.

SesiStore dan penyambungan di main.dart#

dart lib/services/sesi_store.dart
// tidak-dijalankan
// Lingkup state sesi: token hasil autentikasi dan status masuk pengguna.
import 'package:flutter/foundation.dart';

import 'catatan_service.dart';

class SesiStore extends ChangeNotifier {
  SesiStore({required this.service});

  final CatatanService service;

  String? _token;

  bool get sudahMasuk => _token != null;
  String? get token => _token;

  // Memeriksa nama pengguna dan sandi, lalu menyimpan token bila berhasil.
  Future<bool> masuk(String namaPengguna, String sandi) async {
    try {
      _token = await service.masuk(namaPengguna, sandi);
    } catch (error) {
      debugPrint('Autentikasi gagal: $error');
      return false;
    }
    notifyListeners();
    return true;
  }

  // Mengakhiri sesi dan mengembalikan aplikasi ke keadaan belum masuk.
  void keluar() {
    _token = null;
    notifyListeners();
  }
}

SesiStore hampir tidak menyimpan apa pun selain token dan statusnya, dan justru kesederhanaan itulah tujuannya. Method masuk mengembalikan nilai boolean alih-alih melempar error, karena kegagalan autentikasi adalah keadaan yang wajar bagi layar masuk dan bukan sesuatu yang luar biasa. Pada lingkup ini token hanya ada di memori selama aplikasi berjalan, dan menyimpannya kembali dengan pola simpan lokal dibiarkan sebagai pekerjaan lanjutan.

Berkas main.dart menyambungkan semuanya, yaitu dua penyedia state, guard dari topik desain yang dipindahkan apa adanya, dan tiga route yang sama dengan rencana pada topik desain.

dart main.dart
// tidak-dijalankan
// Titik masuk aplikasi catatan: provider, guard, dan router disusun di sini.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';

import 'package:aplikasi_catatan/screens/layar_daftar.dart';
import 'package:aplikasi_catatan/screens/layar_masuk.dart';
import 'package:aplikasi_catatan/screens/layar_ubah.dart';
import 'package:aplikasi_catatan/services/catatan_service.dart';
import 'package:aplikasi_catatan/services/catatan_store.dart';
import 'package:aplikasi_catatan/services/sesi_store.dart';

void main() {
  runApp(const AplikasiCatatan());
}

class AplikasiCatatan extends StatelessWidget {
  const AplikasiCatatan({super.key});

  static final GoRouter _router = GoRouter(
    initialLocation: '/',
    redirect: (context, state) {
      final sesi = context.read<SesiStore>();
      final menujuMasuk = state.matchedLocation == '/masuk';
      if (!sesi.sudahMasuk && !menujuMasuk) {
        return '/masuk';
      }
      if (sesi.sudahMasuk && menujuMasuk) {
        return '/';
      }
      return null;
    },
    routes: [
      GoRoute(
        path: '/masuk',
        builder: (context, state) => const LayarMasuk(),
      ),
      GoRoute(
        path: '/',
        builder: (context, state) => const LayarDaftar(),
      ),
      GoRoute(
        path: '/ubah/:id',
        builder: (context, state) =>
            LayarUbah(id: state.pathParameters['id']!),
      ),
    ],
  );

  @override
  Widget build(BuildContext context) {
    final service = CatatanService(baseUrl: 'https://example.com/api');
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => SesiStore(service: service)),
        ChangeNotifierProvider(create: (_) => CatatanStore(service: service)),
      ],
      child: MaterialApp.router(
        title: 'Aplikasi Catatan',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        ),
        routerConfig: AplikasiCatatan._router,
      ),
    );
  }
}

MultiProvider dipasang di atas MaterialApp.router agar callback redirect yang berada di dalam router masih dapat membaca SesiStore. Alamat https://example.com/api hanyalah pengganti, karena domain example.com memang dicadangkan untuk dokumentasi, sehingga alamat itu diganti dengan alamat API latihan yang digunakan. Service dibuat satu kali dan diberikan kepada kedua store melalui konstruktor, sehingga mengganti penerapannya cukup dilakukan di satu tempat.

Layar daftar dan layar ubah#

Layar daftar menggabungkan FutureBuilder untuk keadaan pemuatan dan Provider untuk isinya, sebagaimana kerangka berikut.

dart Contoh: LayarDaftar dengan FutureBuilder dan Provider
// tidak-dijalankan
class LayarDaftar extends StatefulWidget {
  const LayarDaftar({super.key});

  @override
  State<LayarDaftar> createState() => _LayarDaftarState();
}

class _LayarDaftarState extends State<LayarDaftar> {
  late final Future<void> _muat;

  @override
  void initState() {
    super.initState();
    final token = context.read<SesiStore>().token;
    _muat = context.read<CatatanStore>().muat(token: token);
  }

  @override
  Widget build(BuildContext context) {
    final catatan = context.watch<CatatanStore>().catatan;
    return Scaffold(
      appBar: AppBar(title: const Text('Catatan')),
      body: FutureBuilder<void>(
        future: _muat,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }
          if (catatan.isEmpty) {
            return const Center(child: Text('Belum ada catatan'));
          }
          return ListView.builder(
            itemCount: catatan.length,
            itemBuilder: (context, indeks) {
              final entri = catatan[indeks];
              return ListTile(
                title: Text(entri.judul),
                subtitle: Text(
                  entri.isi,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                ),
                onTap: () => context.push('/ubah/${entri.id}'),
              );
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.push('/ubah/baru'),
        child: const Icon(Icons.add),
      ),
    );
  }
}

Future pemuatan dibentuk sekali pada initState sesuai kebiasaan FutureBuilder, sedangkan isi daftar dibaca melalui context.watch, sehingga perubahan dari layar ubah tetap diterima meskipun layar daftar tidak sedang memuat apa pun. Cabang memuat, kosong, dan terisi persis mengikuti percabangan yang diperkenalkan pada pembahasan FutureBuilder.

Layar masuk hanya memuat dua TextFormField dan memanggil SesiStore melalui handler berikut.

dart Contoh: handler tombol Masuk pada LayarMasuk
// tidak-dijalankan
Future<void> _kirim() async {
  final sesi = context.read<SesiStore>();
  final berhasil = await sesi.masuk(_kontrolPengguna.text, _kontrolSandi.text);
  if (!mounted) {
    return;
  }
  if (berhasil) {
    context.go('/');
  } else {
    setState(() => _pesan = 'Nama pengguna atau sandi salah');
  }
}

Pemeriksaan mounted dilakukan setelah await karena widget bisa saja sudah lepas dari pohon widget ketika permintaan selesai. Perpindahan menggunakan context.go('/') agar tumpukan navigasi tidak menyisakan layar masuk. Layar ubah mengikuti pola yang sama, yaitu membaca widget.id, memanggil cari() untuk mengisi form apabila widget.id bukan baru, membentuk Catatan dengan id yang dibentuk dari waktu saat ini apabila catatan masih baru, lalu memanggil tambah atau ubah sebelum kembali dengan context.go('/').

Kesalahan umum#

  • Menyuruh service menampilkan pesan ke layar. Kelas yang memanggil API sekaligus menampilkan SnackBar membuat lapisan tercampur dan tidak dapat diuji tanpa layar. Perbaikannya, service cukup mengembalikan Future, dan layarlah yang bereaksi terhadap hasilnya.
  • Menggunakan context setelah await tanpa memeriksa mounted. Widget bisa sudah lepas dari pohon widget ketika permintaan selesai, sehingga pemanggilan berikutnya melempar error. Perbaikannya, periksa mounted tepat setelah setiap await sebelum menggunakan context.
  • Menuliskan alamat API di banyak berkas. Alamat yang tersebar membuat penggantian dari server latihan ke server lain menjadi pencarian manual. Perbaikannya, alamat hanya ditulis satu kali pada main.dart sebagai baseUrl dan diteruskan melalui konstruktor.

Ringkasan#

  • Direktori proyek mengikuti lapisan desain, yaitu models untuk kelas data, services untuk jaringan dan state, screens untuk layar, dan widgets untuk komponen yang digunakan ulang.
  • CatatanService menjadi satu-satunya tempat URL, header, dan pemeriksaan statusCode, dan klien http-nya dapat diganti untuk pengujian.
  • CatatanStore memuat lokal terlebih dahulu lalu API, menyimpan setiap perubahan ke shared_preferences, dan meneruskan pengirimannya tanpa menggagalkan layar saat jaringan bermasalah.
  • main.dart memasang MultiProvider di atas MaterialApp.router, sehingga guard dan kedua store siap sebelum layar pertama dibangun.

Referensi#