Chương 04 · Material vs Cupertino, Theming

Material vs Cupertino, Theming

App Flutter thường ship cả iOS + Android. Theme đúng cách tránh hardcode Color(0xFF...) rải khắp. Pattern theme đúng = tap-into design system một chỗ, đổi 1 line đổi toàn app. Đây là cú khá khó cho dev mới — đa số viết Flutter đầu đời hardcode color hết.

Độ dài: ~1020 dòng Bài tập: 5 Quiz: 8 Prerequisites: Ch 2, 3
🎯 Mục tiêu chương
  • Phân biệt Material (Android-style) vs Cupertino (iOS-style).
  • Sử dụng ThemeData Material 3 với ColorScheme.fromSeed.
  • Hiểu 15 text style Material 3.
  • Apply theme qua Theme.of(context).colorScheme.primary.
  • Dark mode: themeMode: ThemeMode.system, define cả theme + darkTheme.
  • Custom ThemeExtension cho semantic color không có trong Material spec.
  • Typography với google_fonts package.
  • Custom CupertinoTheme cho phần iOS-style.

1. Material vs Cupertino — chọn cái nào?

Material (Google)Cupertino (Apple)
LookAndroid-style, Google design systemiOS-style native
Cross-platform✅ Giống nhau iOS/Android❌ Chỉ iOS look
WidgetButton, AppBar, Drawer, Snackbar, ...CupertinoButton, CupertinoNavigationBar, ...
AnimationRipple, slideiOS-style spring, swipe back
App phổ biếnĐa số Flutter appApp muốn match iOS native (banking, gov)

3 lựa chọn thực tế:

  1. Material 100% — most common. App look giống nhau iOS/Android. Đơn giản nhất.
  2. Cupertino 100% — app iOS-only hoặc bắt buộc iOS feel trên cả 2.
  3. Adaptive mix — Material làm khung, một số widget swap sang Cupertino ở iOS qua Theme.of(context).platform. Phức tạp, ít team làm.
💡 Recommend cho IT Basic curriculum

Dùng Material 100% cho 14/15 chương Flutter. Chương 4 này có section Cupertino nhỏ để biết khi cần. App professional gần như đều Material 3 hiện tại.

2. MaterialApp properties chính

MaterialApp(
  title: 'My App',                        // dùng cho task switcher
  theme: ThemeData(/*...*/),
  darkTheme: ThemeData(/*...*/),
  themeMode: ThemeMode.system,             // auto theo OS

  home: const HomePage(),

  // Hoặc routing (chương 5 đi sâu)
  routes: {'/login': (ctx) => const LoginPage()},
  onGenerateRoute: (settings) => null,

  // Localization
  localizationsDelegates: const [/*...*/],
  supportedLocales: const [Locale('vi'), Locale('en')],

  // Debug
  debugShowCheckedModeBanner: false,
)

3. Material 3 vs Material 2

Material 3 (M3) là mặc định từ Flutter 3.16+. Đặc điểm:

  • Dynamic color (theo wallpaper user trên Android 12+).
  • Component lớn hơn, rounded corner đậm.
  • Typography scale mới (15 style).
  • Color slot 25+ thay vì primary/accent cũ.
ThemeData(
  useMaterial3: true,    // bắt buộc cho M3 (default từ 3.16)
  // ... rest
)

4. ColorScheme.fromSeed — M3 way

ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(
    seedColor: Colors.deepPurple,
    brightness: Brightness.light,
  ),
)

fromSeed nhận 1 màu gốc + sinh tự động 25+ color slot match design system. 3 seed khác nhau → 3 palette khác nhau.

25 color slot chính

SlotUse case
primaryPrimary action — button background, header
onPrimaryContent (text/icon) trên primary
primaryContainerTinted background cho primary content
onPrimaryContainerContent trên primaryContainer
secondaryLess prominent action
onSecondaryContent trên secondary
tertiaryContrasting accent
errorError state, destructive action
surfaceCard, sheet background
onSurfaceBody text, icon trên surface
backgroundApp background
outlineBorder, divider
inverseSurfaceSnackbar background
🧠 Pattern onX

Mỗi background color (primary, surface, ...) có pair onX — màu content đặt LÊN nó. Đảm bảo contrast accessibility. Khi gán: color: scheme.primary phải kèm foregroundColor: scheme.onPrimary.

5. Text theme — 15 style M3

TierStyleUse case
DisplaydisplayLarge (57)Hero number, marketing
displayMedium (45)Section hero
displaySmall (36)Compact hero
HeadlineheadlineLarge (32)Page title
headlineMedium (28)Section heading
headlineSmall (24)Card title
TitletitleLarge (22)Dialog title
titleMedium (16, w500)List item title
titleSmall (14, w500)Small card title
BodybodyLarge (16)Body text
bodyMedium (14)Body default
bodySmall (12)Caption
LabellabelLarge (14, w500)Button label
labelMedium (12, w500)Chip, tab
labelSmall (11, w500)Badge

6. Apply theme trong widget

@override
Widget build(BuildContext context) {
  final theme = Theme.of(context);
  final scheme = theme.colorScheme;
  final text = theme.textTheme;

  return Container(
    color: scheme.primary,                       // background
    padding: const EdgeInsets.all(16),
    child: Text(
      'Hello',
      style: text.headlineMedium?.copyWith(color: scheme.onPrimary),
    ),
  );
}
⚠️ Đừng hardcode color/textStyle
// ❌ Hardcode — không follow theme
Container(color: Color(0xFF1A73E8), ...)
Text('Hi', style: TextStyle(fontSize: 24, color: Colors.blue))

// ✅ Theme-aware — follow theme, swap dark mode auto
Container(color: Theme.of(context).colorScheme.primary, ...)
Text('Hi', style: Theme.of(context).textTheme.headlineMedium)

7. google_fonts package

flutter pub add google_fonts
import 'package:google_fonts/google_fonts.dart';

ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
  textTheme: GoogleFonts.interTextTheme(),    // Inter cho mọi style
)

// Hoặc mix display + body khác font
ThemeData(
  textTheme: TextTheme(
    displayLarge: GoogleFonts.fraunces(fontSize: 57),
    bodyLarge: GoogleFonts.inter(fontSize: 16),
  ),
)
⚠️ Production: bundle font file

Mặc định google_fonts fetch font runtime (cần internet). Production nên:

  1. Tải font file (.ttf) từ fonts.google.com.
  2. Đặt vào assets/fonts/, declare trong pubspec.yaml.
  3. GoogleFonts.config.allowRuntimeFetching = false; để force dùng bundled.

8. Dark mode

MaterialApp(
  theme: ThemeData(
    useMaterial3: true,
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.deepPurple,
      brightness: Brightness.light,
    ),
  ),
  darkTheme: ThemeData(
    useMaterial3: true,
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.deepPurple,
      brightness: Brightness.dark,
    ),
  ),
  themeMode: ThemeMode.system,    // light/dark/system
)

Toggle runtime:

final _themeMode = ValueNotifier(ThemeMode.system);

ValueListenableBuilder<ThemeMode>(
  valueListenable: _themeMode,
  builder: (context, mode, _) => MaterialApp(
    theme: ThemeData(/*...*/),
    darkTheme: ThemeData(/*...*/),
    themeMode: mode,
    home: HomePage(onToggle: () => _themeMode.value = mode == ThemeMode.dark
        ? ThemeMode.light : ThemeMode.dark),
  ),
)

9. ThemeExtension — custom slot

Material không có success/warning. Cách thêm type-safe:

class BrandColors extends ThemeExtension<BrandColors> {
  final Color success;
  final Color warning;
  final Color info;

  const BrandColors({required this.success, required this.warning, required this.info});

  @override
  BrandColors copyWith({Color? success, Color? warning, Color? info}) =>
      BrandColors(
        success: success ?? this.success,
        warning: warning ?? this.warning,
        info: info ?? this.info,
      );

  @override
  BrandColors lerp(ThemeExtension<BrandColors>? other, double t) {
    if (other is! BrandColors) return this;
    return BrandColors(
      success: Color.lerp(success, other.success, t)!,
      warning: Color.lerp(warning, other.warning, t)!,
      info: Color.lerp(info, other.info, t)!,
    );
  }
}

// Apply
ThemeData(
  extensions: const [
    BrandColors(
      success: Color(0xFF22C55E),
      warning: Color(0xFFEAB308),
      info: Color(0xFF3B82F6),
    ),
  ],
)

// Access
final brand = Theme.of(context).extension<BrandColors>()!;
Container(color: brand.success);

10. Cupertino — iOS-style

import 'package:flutter/cupertino.dart';

CupertinoApp(
  theme: const CupertinoThemeData(
    primaryColor: CupertinoColors.activeBlue,
    brightness: Brightness.light,
  ),
  home: CupertinoPageScaffold(
    navigationBar: const CupertinoNavigationBar(
      middle: Text('Cupertino App'),
    ),
    child: Center(
      child: CupertinoButton(
        onPressed: () {},
        child: const Text('Press me'),
      ),
    ),
  ),
)

Adaptive widget — auto switch theo platform

.adaptive()   // suffix cho một số widget

Switch.adaptive(value: true, onChanged: (v) {})  // iOS: CupertinoSwitch, Android: Switch
CircularProgressIndicator.adaptive()
Icons.adaptive.arrow_back      // icon khác iOS/Android

11. Ví dụ đầy đủ — Coffee app theme

final coffeeTheme = ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(
    seedColor: const Color(0xFF6F4E37),       // nâu cà phê
    brightness: Brightness.light,
  ),
  textTheme: GoogleFonts.manropeTextTheme(),
  appBarTheme: const AppBarTheme(centerTitle: true),
  cardTheme: CardTheme(
    elevation: 2,
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  ),
  elevatedButtonTheme: ElevatedButtonThemeData(
    style: ElevatedButton.styleFrom(
      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
    ),
  ),
  extensions: const [
    CoffeeColors(
      espresso: Color(0xFF3E2723),
      latte: Color(0xFFFFF8E1),
      cappuccino: Color(0xFFD7CCC8),
    ),
  ],
);

12. Bài tập

Coffee shop theme

Setup ThemeData cho app shop coffee: seed color brown, Google Fonts Manrope (body) + Lora (display). Apply vào MaterialApp. Demo trên 2 page (home + detail).

💡 Gợi ý đáp án
ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6F4E37)),
  textTheme: GoogleFonts.manropeTextTheme().copyWith(
    displayLarge: GoogleFonts.lora(fontSize: 57, fontWeight: FontWeight.w600),
    displayMedium: GoogleFonts.lora(fontSize: 45, fontWeight: FontWeight.w600),
    headlineLarge: GoogleFonts.lora(fontSize: 32),
  ),
)

Mix Manrope (sans-serif body) + Lora (serif display) — phổ biến cho cafe brand.

CoffeeColors extension

Add ThemeExtension CoffeeColors với 5 custom color (espresso, latte, cappuccino, macchiato, mocha). Use trong card menu.

💡 Gợi ý đáp án

Implement theo pattern section 9. Apply qua Theme.of(context).extension<CoffeeColors>()!.

Dark mode toggle persistent

Implement toggle dark mode bằng ValueNotifier<ThemeMode> + button trong AppBar. Persistent qua SharedPreferences (chương 11 sẽ học chi tiết, có thể skip persistent ở đây).

💡 Gợi ý đáp án
final themeMode = ValueNotifier<ThemeMode>(ThemeMode.system);

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<ThemeMode>(
      valueListenable: themeMode,
      builder: (_, mode, __) => MaterialApp(
        theme: ThemeData.light(useMaterial3: true),
        darkTheme: ThemeData.dark(useMaterial3: true),
        themeMode: mode,
        home: HomePage(),
      ),
    );
  }
}

// Trong AppBar:
IconButton(
  icon: const Icon(Icons.brightness_6),
  onPressed: () {
    themeMode.value = themeMode.value == ThemeMode.dark
        ? ThemeMode.light
        : ThemeMode.dark;
  },
)

Refactor hardcode → theme

Cho 1 màn hình có hardcode color/textStyle. Refactor thành dùng Theme.of(context). Demo: đổi seedColor → màn hình tự update.

💡 Gợi ý đáp án
// Trước
Container(
  color: const Color(0xFF1A73E8),
  child: Text('Hi', style: TextStyle(color: Colors.white, fontSize: 24)),
)

// Sau
final scheme = Theme.of(context).colorScheme;
final text = Theme.of(context).textTheme;
Container(
  color: scheme.primary,
  child: Text('Hi', style: text.headlineMedium?.copyWith(color: scheme.onPrimary)),
)

AppButton variant

Tạo widget AppButton(label, onPressed, variant) đọc theme, support 3 variant: primary / secondary / text. Demo 3 button cạnh nhau.

💡 Gợi ý đáp án
enum AppButtonVariant { primary, secondary, text }

class AppButton extends StatelessWidget {
  final String label;
  final VoidCallback? onPressed;
  final AppButtonVariant variant;

  const AppButton({
    super.key,
    required this.label,
    this.onPressed,
    this.variant = AppButtonVariant.primary,
  });

  @override
  Widget build(BuildContext context) {
    switch (variant) {
      case AppButtonVariant.primary:
        return ElevatedButton(onPressed: onPressed, child: Text(label));
      case AppButtonVariant.secondary:
        return OutlinedButton(onPressed: onPressed, child: Text(label));
      case AppButtonVariant.text:
        return TextButton(onPressed: onPressed, child: Text(label));
    }
  }
}

13. Quiz

Q1

Material 3 vs Material 2 — flag nào enable M3?

Xem đáp án

Đáp án: useMaterial3: true trong ThemeData. Default từ Flutter 3.16+.

Q2

Colors.blue vs scheme.primary — chênh ở đâu?

Xem đáp án

Đáp án: Colors.blue hardcode. scheme.primary follow theme — thay theme là đổi color toàn app.

Q3

ThemeExtension giúp gì?

Xem đáp án

Đáp án: Thêm custom field type-safe (vd success/warning color) không có trong ColorScheme. Access qua Theme.of(context).extension<T>().

Q4

GoogleFonts.inter() production có ổn không?

Xem đáp án

Đáp án: Mặc định fetch runtime — có offline issue. Production: tải font về assets + GoogleFonts.config.allowRuntimeFetching = false.

Q5

Switch.adaptive khác Switch thế nào?

Xem đáp án

Đáp án: iOS render CupertinoSwitch (look iOS), Android render Material Switch. Adaptive = platform-aware auto.

Q6

ColorScheme onPrimary nghĩa là gì?

Xem đáp án

Đáp án: Color của content (text, icon) đặt TRÊN background primary. Pair đảm bảo contrast accessibility.

Q7

Theme đổi runtime — widget có rebuild không?

Xem đáp án

Đáp án: Có. Widget dùng Theme.of(context) register dependency, theme đổi trigger rebuild.

Q8

CupertinoApp có Scaffold không?

Xem đáp án

Đáp án: Không. Cupertino dùng CupertinoPageScaffold (cùng pattern nhưng iOS-style).

14. Tổng kết

  • ✅ Material 3 mặc định, useMaterial3: true.
  • ColorScheme.fromSeed sinh 25+ slot từ 1 seed.
  • ✅ Pattern onX cho content trên background.
  • ✅ 15 text style M3: display/headline/title/body/label × 3 size.
  • ✅ Apply qua Theme.of(context).colorScheme + .textTheme.
  • ThemeExtension cho custom slot type-safe.
  • google_fonts — convenient nhưng production bundle file.
  • ✅ Dark mode: theme + darkTheme + themeMode.
  • ✅ Cupertino cho iOS-style, adaptive widget switch platform.
  • Đừng hardcode color/textStyle — luôn qua theme.

15. Kết nối