Skip to content

Bab 9 — Theming, Styling & Aset

Aplikasi yang memakai warna bawaan Flutter terlihat seperti latihan. Bab ini membahas cara memberinya identitas — warna, tipografi, ikon, gambar, dan animasi — tanpa menaburkan nilai gaya di setiap widget.

Ide utamanya satu: tetapkan gaya sekali di satu tempat, lalu rujuk dari mana-mana.

Masalah dengan gaya inline

dart
Text(
  'Judul',
  style: TextStyle(
    fontSize: 20,
    fontWeight: FontWeight.bold,
    color: Color.fromARGB(255, 78, 13, 151),
  ),
)

Ini bekerja. Tetapi ketika kamu punya empat puluh judul di seluruh aplikasi dan klien meminta warnanya diubah, kamu harus mengedit empat puluh tempat — dan pasti ada yang terlewat.

Tema menyelesaikan ini.

ColorScheme: satu benih, seluruh palet

Material 3 memperkenalkan cara yang sangat efisien: kamu memberi satu warna benih, dan Flutter menurunkan seluruh palet yang harmonis darinya.

dart
// lib/main.dart
import 'package:flutter/material.dart';

final skemaWarna = ColorScheme.fromSeed(
  seedColor: const Color.fromARGB(255, 96, 59, 181),
);

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData().copyWith(
        useMaterial3: true,
        colorScheme: skemaWarna,
        scaffoldBackgroundColor: skemaWarna.surface,
      ),
      home: const Beranda(),
    );
  }
}

Flutter menghasilkan lusinan warna dari satu benih itu, masing-masing dengan peran yang jelas:

PeranKegunaan
primaryWarna utama merek — tombol, tab aktif, penanda
onPrimaryWarna teks/ikon di atas primary
primaryContainerVersi lebih lembut, untuk latar penekanan
onPrimaryContainerTeks di atas primaryContainer
secondaryWarna aksen pendamping
surfaceLatar kartu dan permukaan
onSurfaceTeks utama di atas permukaan
error / onErrorKeadaan kesalahan
outlineGaris tepi dan pembatas

Pola penamaan on* sangat membantu: setiap kali kamu memakai warna latar X, warna teks yang benar di atasnya adalah onX. Kontrasnya sudah dijamin.

Membaca tema dari widget

dart
@override
Widget build(BuildContext context) {
  final skema = Theme.of(context).colorScheme;

  return Container(
    color: skema.primaryContainer,
    padding: const EdgeInsets.all(16),
    child: Text(
      'Teks yang kontrasnya terjamin',
      style: TextStyle(color: skema.onPrimaryContainer),
    ),
  );
}

Theme.of(context) menelusuri widget tree ke atas sampai menemukan Theme terdekat. Karena MaterialApp menyediakan satu di puncak, ini selalu berhasil.

Simpan hasilnya di variabel

Menulis Theme.of(context).colorScheme.primary berkali-kali dalam satu build() itu berulang dan sedikit boros. Ambil sekali di awal:

dart
final skema = Theme.of(context).colorScheme;
final teks = Theme.of(context).textTheme;

TextTheme: tipografi terpusat

Sama seperti warna, ukuran dan berat teks sebaiknya ditetapkan sekali.

Material 3 menyediakan lima belas gaya teks bawaan yang tersusun dalam tiga keluarga:

display  → displayLarge   displayMedium   displaySmall     (sangat besar)
headline → headlineLarge  headlineMedium  headlineSmall    (judul halaman)
title    → titleLarge     titleMedium     titleSmall       (judul bagian)
body     → bodyLarge      bodyMedium      bodySmall        (isi teks)
label    → labelLarge     labelMedium     labelSmall       (tombol, kapsi)

Memakainya:

dart
Text('Judul Besar', style: Theme.of(context).textTheme.headlineMedium)
Text('Isi paragraf', style: Theme.of(context).textTheme.bodyMedium)

Menyesuaikannya secara global:

dart
theme: ThemeData().copyWith(
  useMaterial3: true,
  colorScheme: skemaWarna,
  textTheme: ThemeData().textTheme.copyWith(
        titleLarge: TextStyle(
          fontWeight: FontWeight.bold,
          color: skemaWarna.onSecondaryContainer,
          fontSize: 16,
        ),
        bodyMedium: TextStyle(
          color: skemaWarna.onSurface,
          fontSize: 15,
          height: 1.5,
        ),
      ),
),

Menyesuaikan sebagian gaya di satu tempat, dengan copyWith:

dart
Text(
  'Judul dengan warna berbeda',
  style: Theme.of(context).textTheme.titleLarge!.copyWith(
        color: Colors.white,
        fontSize: 24,
      ),
)

copyWith mengambil gaya yang sudah ada dan hanya mengganti properti yang kamu sebutkan. Ini jauh lebih baik daripada menulis TextStyle dari nol, karena sisanya tetap mengikuti tema.

Tema komponen

Kamu juga bisa menetapkan gaya bawaan untuk jenis widget tertentu, sehingga tidak perlu mengulanginya:

dart
theme: ThemeData().copyWith(
  useMaterial3: true,
  colorScheme: skemaWarna,

  // Semua AppBar di aplikasi
  appBarTheme: const AppBarTheme().copyWith(
    backgroundColor: skemaWarna.onPrimaryContainer,
    foregroundColor: skemaWarna.primaryContainer,
    centerTitle: true,
    elevation: 0,
  ),

  // Semua Card
  cardTheme: const CardThemeData().copyWith(
    color: skemaWarna.secondaryContainer,
    margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
    elevation: 2,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(12),
    ),
  ),

  // Semua ElevatedButton
  elevatedButtonTheme: ElevatedButtonThemeData(
    style: ElevatedButton.styleFrom(
      backgroundColor: skemaWarna.primaryContainer,
      foregroundColor: skemaWarna.onPrimaryContainer,
      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(24),
      ),
    ),
  ),

  // Semua kolom input
  inputDecorationTheme: InputDecorationTheme(
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
    ),
    filled: true,
    fillColor: skemaWarna.surfaceContainerHighest,
  ),
),

Sekarang setiap ElevatedButton di aplikasimu otomatis membulat dan berwarna sesuai merek, tanpa satu pun style: di widget-nya.

Nama kelas tema berubah

Sejak Flutter 3.29, CardTheme diganti CardThemeData, DialogTheme menjadi DialogThemeData, dan beberapa lainnya mengikuti pola serupa. Kalau kamu menemukan error tipe ketika menyalin kode dari tutorial lama, periksa apakah kelasnya sudah berakhiran ThemeData.

Mode gelap

Menyediakan tema gelap hanya butuh satu parameter tambahan:

dart
final skemaTerang = ColorScheme.fromSeed(
  seedColor: const Color.fromARGB(255, 96, 59, 181),
);

final skemaGelap = ColorScheme.fromSeed(
  brightness: Brightness.dark,
  seedColor: const Color.fromARGB(255, 5, 99, 125),
);

MaterialApp(
  theme: ThemeData().copyWith(
    useMaterial3: true,
    colorScheme: skemaTerang,
    // ... tema komponen
  ),
  darkTheme: ThemeData.dark().copyWith(
    useMaterial3: true,
    colorScheme: skemaGelap,
    cardTheme: const CardThemeData().copyWith(
      color: skemaGelap.secondaryContainer,
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
    ),
  ),
  themeMode: ThemeMode.system,   // ikuti pengaturan perangkat
  home: const Beranda(),
)

Nilai themeMode yang tersedia:

NilaiPerilaku
ThemeMode.systemMengikuti pengaturan perangkat — bawaan
ThemeMode.lightSelalu terang
ThemeMode.darkSelalu gelap

Kalau kamu ingin pengguna bisa memilih sendiri, themeMode perlu disimpan sebagai state aplikasi. Solusi sederhananya memakai ValueNotifier dari Bab 5; solusi yang lebih rapi memakai Riverpod (Bab 18) atau BLoC (Bab 21).

Untuk memeriksa mode aktif dari dalam widget:

dart
final gelap = Theme.of(context).brightness == Brightness.dark;

Icon(
  gelap ? Icons.dark_mode : Icons.light_mode,
)

Font kustom

Ada dua cara memakai font di luar bawaan sistem.

Cara pertama: google_fonts

Paling cepat, tanpa mengunduh berkas apa pun:

yaml
# pubspec.yaml
dependencies:
  google_fonts: ^6.2.1
dart
import 'package:google_fonts/google_fonts.dart';

MaterialApp(
  theme: ThemeData().copyWith(
    colorScheme: skemaWarna,
    textTheme: GoogleFonts.latoTextTheme(),   // seluruh tema teks
  ),
)

Atau untuk satu gaya saja:

dart
Text(
  'Judul',
  style: GoogleFonts.lato(
    textStyle: Theme.of(context).textTheme.titleLarge,
    fontWeight: FontWeight.bold,
  ),
)

Font diunduh saat runtime

Secara bawaan google_fonts mengunduh berkas font pada penggunaan pertama. Ini berarti teks bisa berkedip pada peluncuran pertama, dan aplikasi butuh koneksi internet. Untuk aplikasi produksi, unduh berkas fontnya dan daftarkan sebagai aset — seperti cara kedua di bawah.

Cara kedua: berkas font lokal

Unduh berkas .ttf, taruh di proyek, lalu daftarkan:

assets/
└── fonts/
    ├── Poppins-Regular.ttf
    ├── Poppins-Medium.ttf
    ├── Poppins-SemiBold.ttf
    └── Poppins-Bold.ttf
yaml
# pubspec.yaml
flutter:
  uses-material-design: true

  fonts:
    - family: Poppins
      fonts:
        - asset: assets/fonts/Poppins-Regular.ttf
          weight: 400
        - asset: assets/fonts/Poppins-Medium.ttf
          weight: 500
        - asset: assets/fonts/Poppins-SemiBold.ttf
          weight: 600
        - asset: assets/fonts/Poppins-Bold.ttf
          weight: 700
dart
theme: ThemeData(
  fontFamily: 'Poppins',   // nama family, bukan nama berkas
  colorScheme: skemaWarna,
),

Setelah itu, FontWeight.w600 otomatis memakai berkas SemiBold. Nilai weight di YAML harus cocok dengan angka FontWeight di Dart.

Aset gambar

Mendaftarkan

yaml
flutter:
  assets:
    - assets/gambar/
    - assets/ikon/

Garis miring di akhir mendaftarkan seluruh isi folder. Untuk berkas tertentu:

yaml
    - assets/gambar/logo.png

Setelah mengubah pubspec.yaml

Jalankan hot restart (R), bukan hot reload (r). Perubahan pada pubspec.yaml tidak terdeteksi oleh hot reload. Kalau masih tidak muncul, hentikan aplikasi dan jalankan flutter pub get lalu flutter run lagi.

Resolusi ganda

Untuk gambar yang tajam di layar beresolusi tinggi, sediakan beberapa ukuran:

assets/gambar/
├── logo.png          ← 1x (misalnya 100×100)
├── 2.0x/
│   └── logo.png      ← 2x (200×200)
└── 3.0x/
    └── logo.png      ← 3x (300×300)

Kamu tetap menulis Image.asset('assets/gambar/logo.png'), dan Flutter memilih berkas yang sesuai dengan kepadatan piksel perangkat secara otomatis.

Menampilkan

dart
// Dari aset
Image.asset(
  'assets/gambar/logo.png',
  width: 120,
  fit: BoxFit.contain,
)

// Dari internet, dengan penanganan muat & gagal
Image.network(
  urlGambar,
  height: 200,
  width: double.infinity,
  fit: BoxFit.cover,
  loadingBuilder: (context, child, progres) {
    if (progres == null) return child;
    return SizedBox(
      height: 200,
      child: Center(
        child: CircularProgressIndicator(
          value: progres.expectedTotalBytes != null
              ? progres.cumulativeBytesLoaded / progres.expectedTotalBytes!
              : null,
        ),
      ),
    );
  },
  errorBuilder: (context, error, stack) => Container(
    height: 200,
    color: Colors.grey.shade200,
    child: const Icon(Icons.broken_image, size: 48),
  ),
)

Untuk gambar jaringan di daftar panjang, paket cached_network_image menyimpan gambar di cache sehingga tidak diunduh ulang setiap kali digulir:

yaml
dependencies:
  cached_network_image: ^3.4.1
dart
CachedNetworkImage(
  imageUrl: urlGambar,
  placeholder: (ctx, url) => const Center(
    child: CircularProgressIndicator(),
  ),
  errorWidget: (ctx, url, error) => const Icon(Icons.error),
  fit: BoxFit.cover,
)

CircleAvatar

Widget untuk foto profil bundar:

dart
// Dari jaringan
CircleAvatar(
  radius: 32,
  backgroundImage: NetworkImage(urlFoto),
)

// Dari aset
const CircleAvatar(
  radius: 32,
  backgroundImage: AssetImage('assets/gambar/profil.png'),
)

// Inisial sebagai cadangan
CircleAvatar(
  radius: 32,
  backgroundColor: Colors.indigo,
  child: Text(
    'FN',
    style: const TextStyle(color: Colors.white, fontSize: 20),
  ),
)

Pola yang aman: tampilkan foto kalau ada, inisial kalau tidak.

dart
CircleAvatar(
  radius: 32,
  backgroundColor: Colors.indigo,
  backgroundImage: urlFoto != null ? NetworkImage(urlFoto!) : null,
  child: urlFoto == null
      ? Text(
          inisial,
          style: const TextStyle(color: Colors.white, fontSize: 20),
        )
      : null,
)

Ketika backgroundImage terisi, child disembunyikan di belakangnya — jadi memberi keduanya sekaligus tidak menyebabkan error, tetapi memberi null pada yang tidak dipakai lebih jelas maksudnya.

Animasi Lottie

Lottie memungkinkan kamu memakai animasi vektor yang dibuat di After Effects — jauh lebih ringan daripada GIF dan tetap tajam di ukuran berapa pun.

yaml
dependencies:
  lottie: ^3.1.3
dart
import 'package:lottie/lottie.dart';

// Dari aset lokal
Lottie.asset(
  'assets/animasi/loading.json',
  width: 200,
  height: 200,
  fit: BoxFit.contain,
  repeat: true,
)

// Dari internet
Lottie.network(
  'https://assets.lottiefiles.com/packages/contoh.json',
  width: 200,
)

Untuk mengendalikan pemutarannya secara manual:

dart
class _LayarSuksesState extends State<LayarSukses>
    with SingleTickerProviderStateMixin {
  late final AnimationController _pengendali;

  @override
  void initState() {
    super.initState();
    _pengendali = AnimationController(vsync: this);
  }

  @override
  void dispose() {
    _pengendali.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Lottie.asset(
        'assets/animasi/sukses.json',
        controller: _pengendali,
        repeat: false,
        onLoaded: (komposisi) {
          // Sesuaikan durasi controller dengan durasi animasi,
          // lalu putar sekali.
          _pengendali
            ..duration = komposisi.duration
            ..forward();
        },
      ),
    );
  }
}

AnimationController dan SingleTickerProviderStateMixin dibahas tuntas di Bab 11.

Sumber animasi gratis: LottieFiles.

FittedBox

Widget kecil yang menyelamatkan banyak masalah teks meluber. Ia mengecilkan anaknya agar muat di ruang yang tersedia:

dart
SizedBox(
  width: 120,
  child: FittedBox(
    fit: BoxFit.scaleDown,   // hanya mengecilkan, tidak membesarkan
    child: Text(
      'Teks yang mungkin sangat panjang',
      style: TextStyle(fontSize: 24),
      maxLines: 1,
    ),
  ),
)

Berbeda dari overflow: TextOverflow.ellipsis yang memotong teks, FittedBox mengecilkan ukurannya sehingga seluruh teks tetap terbaca. Berguna untuk angka yang panjangnya tidak bisa diprediksi — misalnya nominal harga.

Ikon peluncur aplikasi

Ikon yang muncul di layar utama ponsel bukan aset Flutter biasa; ia harus ada dalam berbagai ukuran di folder Android dan iOS. Paket flutter_launcher_icons mengurus semuanya.

yaml
# pubspec.yaml
dev_dependencies:
  flutter_launcher_icons: ^0.14.1

flutter_launcher_icons:
  android: true
  ios: true
  image_path: "assets/ikon/ikon_app.png"
  min_sdk_android: 21
  adaptive_icon_background: "#603BB5"
  adaptive_icon_foreground: "assets/ikon/ikon_depan.png"

Jalankan sekali:

bash
dart run flutter_launcher_icons

Persyaratan gambarnya: PNG persegi, minimal 1024×1024 piksel, tanpa transparansi di latar untuk hasil terbaik di iOS.

Contoh: tema aplikasi yang lengkap

Menggabungkan semuanya menjadi satu berkas tema yang bisa kamu jadikan titik awal.

dart
// lib/theme/tema_aplikasi.dart
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';

class TemaAplikasi {
  TemaAplikasi._();   // kelas ini tidak untuk diinstansiasi

  static const _warnaBenih = Color(0xFF603BB5);

  static final skemaTerang = ColorScheme.fromSeed(seedColor: _warnaBenih);

  static final skemaGelap = ColorScheme.fromSeed(
    seedColor: _warnaBenih,
    brightness: Brightness.dark,
  );

  static ThemeData terang() => _bangun(skemaTerang);
  static ThemeData gelap() => _bangun(skemaGelap);

  static ThemeData _bangun(ColorScheme skema) {
    final dasar = ThemeData(
      useMaterial3: true,
      colorScheme: skema,
      brightness: skema.brightness,
    );

    return dasar.copyWith(
      scaffoldBackgroundColor: skema.surface,

      textTheme: GoogleFonts.poppinsTextTheme(dasar.textTheme).copyWith(
        headlineMedium: GoogleFonts.poppins(
          fontSize: 24,
          fontWeight: FontWeight.bold,
          color: skema.onSurface,
        ),
        titleLarge: GoogleFonts.poppins(
          fontSize: 18,
          fontWeight: FontWeight.w600,
          color: skema.onSurface,
        ),
        bodyMedium: GoogleFonts.poppins(
          fontSize: 14,
          height: 1.5,
          color: skema.onSurface,
        ),
      ),

      appBarTheme: AppBarTheme(
        backgroundColor: skema.surface,
        foregroundColor: skema.onSurface,
        elevation: 0,
        scrolledUnderElevation: 2,
        centerTitle: false,
        titleTextStyle: GoogleFonts.poppins(
          fontSize: 20,
          fontWeight: FontWeight.w600,
          color: skema.onSurface,
        ),
      ),

      cardTheme: CardThemeData(
        color: skema.surfaceContainerLow,
        elevation: 0,
        margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(16),
          side: BorderSide(color: skema.outlineVariant),
        ),
      ),

      elevatedButtonTheme: ElevatedButtonThemeData(
        style: ElevatedButton.styleFrom(
          backgroundColor: skema.primary,
          foregroundColor: skema.onPrimary,
          minimumSize: const Size(0, 48),
          padding: const EdgeInsets.symmetric(horizontal: 24),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
          ),
          textStyle: GoogleFonts.poppins(
            fontSize: 15,
            fontWeight: FontWeight.w600,
          ),
        ),
      ),

      inputDecorationTheme: InputDecorationTheme(
        filled: true,
        fillColor: skema.surfaceContainerHighest,
        contentPadding: const EdgeInsets.symmetric(
          horizontal: 16,
          vertical: 14,
        ),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(12),
          borderSide: BorderSide.none,
        ),
        focusedBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(12),
          borderSide: BorderSide(color: skema.primary, width: 2),
        ),
      ),

      dividerTheme: DividerThemeData(
        color: skema.outlineVariant,
        thickness: 1,
        space: 1,
      ),
    );
  }
}

Pemakaiannya menjadi sangat ringkas:

dart
// lib/main.dart
MaterialApp(
  theme: TemaAplikasi.terang(),
  darkTheme: TemaAplikasi.gelap(),
  themeMode: ThemeMode.system,
  home: const Beranda(),
)

Latihan Mandiri

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

Variasi 1: Bersihkan Gaya Inline — ⭐⭐ · 30–45 menit

Tantangan: Ambil layar mana pun yang sudah pernah kamu buat, lalu hapus setiap TextStyle dan Colors.* yang ditulis langsung di dalam widget. Pindahkan semuanya ke satu objek ThemeData di main.dart, dan tambahkan mode gelap yang benar-benar terbaca.

Bayangan tampilan:

text
   Terang                         Gelap
╭───────────────────╮   ╭───────────────────╮
│ 9:41         🔋    │   │ 9:41         🔋    │
│ AppBar (biru muda) │   │ AppBar (biru tua)  │
│ ┌───────────────┐  │   │ ┌───────────────┐  │
│ │ Kartu (putih) │  │   │ │ Kartu (abu tua)│  │
│ │ Teks (hitam)  │  │   │ │ Teks (putih)  │  │
│ └───────────────┘  │   │ └───────────────┘  │
╰───────────────────╯   ╰───────────────────╯

Kedua mode di atas dari satu ColorScheme.fromSeed yang sama — bukan dua ThemeData yang ditulis terpisah — dan tidak ada teks yang "hilang" karena warnanya menyatu dengan latar.

Kriteria selesai:

  • Mencari Colors. di dalam folder screens/ dan widgets/ tidak menemukan satu pun hasil.
  • Seluruh palet diturunkan dari satu warna benih lewat ColorScheme.fromSeed.
  • Gaya berulang seperti AppBar, Card, dan tombol diatur lewat tema komponen, bukan properti style: yang ditulis di setiap pemakaian.
  • Menyalakan mode gelap dari pengaturan perangkat: tidak ada satu pun teks yang hilang karena warnanya sama dengan latarnya.

Petunjuk: Kalau ada warna yang terasa mustahil dipetakan ke ColorScheme, tanyakan dulu peran warna itu — apakah ia warna latar permukaan, warna aksen, atau warna peringatan. Hampir semua warna dalam sebuah aplikasi punya salah satu peran itu, dan ColorScheme sudah menyediakan slot untuk masing-masing beserta pasangan onX-nya yang dijamin cukup kontras. Untuk mode gelap, ColorScheme.fromSeed menerima brightness sehingga kamu tidak perlu menyusun palet kedua dari nol. Dan kalau kamu menyalin kode tema dari tulisan lama, perhatikan beberapa kelas kini berakhiran ThemeDataCardThemeData, bukan CardTheme.

Variasi 2: Identitas Kedai Kopi — ⭐⭐ · 30–45 menit

Tantangan: Bangun tema lengkap untuk aplikasi kedai kopi dari satu warna benih cokelat kehijauan: terang dan gelap, dengan tipografi yang terasa hangat. Buat satu layar demo berisi app bar, kartu menu, tombol, chip, dan bilah bawah untuk membuktikan seluruh komponen mengikuti tema tanpa satu pun gaya lokal.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│ ═══ Kedai Kopi Nusantara ═══       │
│  ┌─────────────────────────────┐  │
│  │ Kopi Susu Gula Aren   25.000 │  │
│  └─────────────────────────────┘  │
│  [Pesan] [Favorit]  [Kopi][Susu]  │
│ ─────────────────────────────────  │
│    🏠        ☕        👤         │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Ganti satu warna benih di kode → app bar, kartu, tombol, chip, dan bilah bawah di atas semuanya ikut berubah otomatis, tanpa menyentuh satu pun kode di layar demo ini.

Kriteria selesai:

  • Seluruh palet terang dan gelap diturunkan dari satu warna benih yang sama.
  • Setiap pasangan warna dan teks di atasnya memakai X dan onX yang berpasangan, bukan warna teks yang ditebak.
  • Layar demo tidak mengandung satu pun TextStyle atau warna literal.
  • Mengganti warna benih di satu tempat mengubah seluruh tampilan aplikasi.

Petunjuk: Kriteria terakhir adalah cara terbaik menguji apakah temamu benar-benar terpusat: ubah warna benihnya menjadi sesuatu yang mencolok seperti ungu terang, jalankan, dan lihat apakah ada bagian yang tetap cokelat. Setiap bagian yang tidak berubah adalah gaya lokal yang lolos dari temamu.

Variasi 3: Tipografi Majalah — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun halaman artikel bergaya majalah dengan hierarki tipografi yang jelas: judul besar, kutipan menonjol, teks isi yang nyaman dibaca, dan keterangan gambar. Pakai dua keluarga font — satu untuk judul, satu untuk isi — dan daftarkan keduanya sebagai aset, bukan diunduh saat runtime.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│                                     │
│  JUDUL BESAR ARTIKEL                │
│                                     │
│  "Kutipan menonjol di sini"        │
│                                     │
│  Teks isi artikel yang nyaman      │
│  dibaca dengan tinggi baris yang   │
│  cukup lega...                     │
│                                     │
│  Gambar oleh: Fotografer X         │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Lima tingkat di atas (judul, kutipan, isi, keterangan, + satu lagi pilihanmu) harus tetap tampil normal dalam mode pesawat — bukti kedua font sudah jadi aset lokal, bukan diunduh saat runtime.

Kriteria selesai:

  • Kedua font terdaftar di pubspec.yaml dengan bobot yang benar, dan aplikasi tetap tampil benar dalam mode pesawat.
  • Seluruh gaya teks berasal dari TextTheme yang disesuaikan lewat copyWith, bukan TextStyle yang ditulis dari nol.
  • Ada minimal lima tingkat hierarki yang bisa dibedakan sekilas.
  • Tinggi baris teks isi disetel sadar untuk kenyamanan membaca, tidak dibiarkan bawaan.

Petunjuk: google_fonts sangat nyaman untuk prototipe tetapi mengunduh font saat aplikasi berjalan — pengguna pertama di jaringan buruk akan melihat font sistem sesaat, dan pengguna tanpa koneksi tidak pernah melihat fontmu sama sekali. Untuk produksi, unduh berkasnya dan daftarkan sebagai aset. Ingat juga bahwa aset butuh hot restart, bukan hot reload — kalau fontmu tidak muncul, itu tersangka pertama sebelum kamu mencurigai pubspec.yaml.

Variasi 4: Tema Komponen Menyeluruh — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun satu berkas tema yang mengatur setiap komponen yang dipakai aplikasi banking sederhana: app bar, kartu, tombol terisi dan bergaris, kolom input, dialog, snackbar, bottom sheet, chip, dan switch. Buat layar demo yang menampilkan semuanya sekaligus.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│ ═══ Bank Demo ═══                  │
│  ┌─────────────────────────────┐  │
│  │ Saldo: Rp 12.500.000         │  │
│  └─────────────────────────────┘  │
│  [ Kirim ]   ( Tarik )            │
│  Nama   [___________]             │
│  ⚠ Nomor rekening tidak valid     │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

...tap "Kirim" → dialog dan snackbar ikut bentuk & warna kartu di atas, tidak tampil beda gaya:

text
┌─────────────────────────────┐
│  Konfirmasi Transaksi?       │
│  [Batal]      [Konfirmasi]   │
└─────────────────────────────┘
   ┌───────────────────────┐
   │ ✓ Transaksi berhasil   │
   └───────────────────────┘

Kriteria selesai:

  • Tidak ada satu pun properti style: atau decoration: di layar demo.
  • Kolom input punya batas, sudut, dan warna fokus yang konsisten di seluruh aplikasi tanpa diulang di tiap kolom.
  • Dialog dan snackbar mengikuti bentuk dan warna yang sama dengan kartu.
  • Berkas tema terpisah dari main.dart dan tidak lebih dari satu berkas.

Petunjuk: Kolom input adalah yang paling sering luput: InputDecorationTheme mengatur seluruh TextField dan TextFormField sekaligus, termasuk warna saat fokus dan saat error. Begitu kamu menyetelnya, seluruh formulir di aplikasi langsung seragam — dan lebih penting lagi, formulir yang kamu tulis enam bulan lagi akan otomatis ikut seragam tanpa kamu ingat apa pun.

Variasi 5: Pemilih Tema oleh Pengguna — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Beri pengguna pilihan lima tema warna dan tiga ukuran teks, tersimpan dan dipulihkan saat aplikasi dibuka kembali. Perubahan harus terlihat seketika di seluruh aplikasi, termasuk layar yang sedang terbuka di belakang layar pengaturan.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  Pilih Tema:                       │
│  (Biru)(Hijau)(Ungu)(Oranye)(Merah)│
│  Ukuran Teks: (Kecil)(Sedang)(Besar)│
│                                     │
│  ┌─ Pratinjau ─────────────────┐  │
│  │  Contoh tampilan tema ini    │  │
│  └─────────────────────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Pilih "Hijau" + "Besar" → layar Beranda yang tadi terbuka di belakang layar pengaturan ini ikut berubah seketika, dan pilihan ini tetap tersimpan setelah aplikasi ditutup paksa lalu dibuka lagi.

Kriteria selesai:

  • Kelima tema diturunkan dari lima warna benih, bukan lima objek tema yang ditulis manual.
  • Ukuran teks menskalakan seluruh TextTheme secara proporsional, bukan hanya satu gaya.
  • Pilihan bertahan setelah aplikasi ditutup paksa lalu dibuka lagi.
  • Layar pratinjau menunjukkan tampilan tema sebelum dipilih.

Petunjuk: Menskalakan seluruh TextTheme sekaligus bisa dilakukan dengan memetakan setiap gayanya lewat copyWith pada ukuran font — atau, lebih rapi lagi, dengan MediaQuery yang menimpa faktor skala teks untuk seluruh subpohon. Yang kedua juga menghormati pengaturan aksesibilitas perangkat, yang membuatnya pilihan yang lebih baik meski terlihat kurang langsung.

Ikhtisar

  • ColorScheme.fromSeed menurunkan seluruh palet dari satu warna benih. Pasangan X dan onX menjamin kontras yang benar.
  • Baca tema lewat Theme.of(context), dan simpan hasilnya di variabel lokal kalau dipakai berkali-kali dalam satu build().
  • TextTheme menyediakan lima belas gaya bawaan. Sesuaikan dengan copyWith, jangan menulis TextStyle dari nol.
  • Tema komponen (appBarTheme, cardTheme, elevatedButtonTheme, dan seterusnya) menghilangkan kebutuhan menulis style: berulang-ulang.
  • Beberapa kelas tema kini berakhiran ThemeData (CardThemeData, DialogThemeData) — periksa ini kalau menyalin kode lama.
  • Mode gelap cukup butuh darkTheme dan themeMode. ThemeMode.system mengikuti pengaturan perangkat.
  • google_fonts cepat tetapi mengunduh saat runtime; untuk produksi, daftarkan berkas font sebagai aset.
  • Aset butuh hot restart, bukan hot reload. Folder 2.0x/ dan 3.0x/ memberikan gambar tajam di layar padat secara otomatis.
  • cached_network_image mencegah pengunduhan ulang di daftar yang digulir.
  • FittedBox mengecilkan teks agar muat, berbeda dari ellipsis yang memotongnya.
  • flutter_launcher_icons menghasilkan seluruh ukuran ikon peluncur dari satu PNG 1024×1024.

Berikutnya: Bab 10 — Responsive & Adaptive UI, agar aplikasimu tidak hanya bagus di satu ukuran layar.

Transkrip asli

Disintesis dari 1_flutter_core/4_expense-tracker_interactive-widget-theming.md (video 27–32), 1_flutter_zero-to-hero/9_package-management-and-modifications.md (Lottie, FittedBox, Launcher Icon, CircleAvatar), dan 1_flutter_core/2_basics.md (video 60, Google Fonts). Lihat PDF Flutter Core dan PDF Zero to Hero.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.