Flutter'da Theme ve Material 3: ThemeData, ColorScheme, Dark Mode, material_ui Paketi ve Cupertino
ColorScheme.fromSeed seed rengini olduğu gibi kullanmaz, ondan light ve dark için okunabilir kontrastlı ayrı paletler üretir; ColorScheme'de yeri olmayan değerler ThemeExtension ile temaya eklenir. Bookstore'un kahverengi temasını, dark mode'u, kendi renk token'larımızı, 3.50'de SDK içi Material'ın deprecated olmasıyla material_ui/cupertino_ui geçişini, adaptive widget'ları ve Türkçe locale ile intl biçimlendirmesini gerçek test çıktılarıyla anlatıyoruz.
Sürüm notu: Bu yazıdaki örnekler Flutter 3.47.8 (Dart 3.13.7), material_ui 1.11.0, cupertino_ui 1.1.6 ve intl 0.20.3 ile denenmiştir. Çıktılar flutter test koşularından ve web build'inin Playwright'ın headless Chromium'unda açılmasından; emulator ve cihazda çalıştırma bu yazı için yapılmadı.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) mobil uygulamasına kendi rengini veriyoruz. Kitap ve kâğıt hissi için koyu bir kahverengi seçtik ve bütün paleti bu tek renkten ürettik:
final scheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF6B4E2E),
brightness: Brightness.light,
);
Butonların, seçili sekmenin ve vurguların rengi olan scheme.primary tam olarak #6B4E2E mi olur? Dark temada da aynı renk mi kullanılır? İki sorunun cevabı da hayır, ve nedeni Material 3'ün renk sistemini nasıl kurduğunu anlatıyor.
ThemeData ve ColorScheme.fromSeed
Flutter'da görünümün tek kaynağı ThemeData: renkler, yazı stilleri ve her component'in varsayılan görünümü orada. Material 3, Flutter 3.16'dan beri varsayılan (useMaterial3 verilmezse true). Bookstore'un teması, resmî mimari rehberinin önerdiği lib/ui/core/themes klasöründe:
abstract final class AppTheme {
/// Bütün paletin üretildiği tek renk.
static const seed = Color(0xFF6B4E2E);
static ThemeData light = _build(Brightness.light, BookstoreColors.light);
static ThemeData dark = _build(Brightness.dark, BookstoreColors.dark);
static ThemeData _build(Brightness brightness, BookstoreColors colors) {
final scheme = ColorScheme.fromSeed(
seedColor: seed,
brightness: brightness,
);
return ThemeData(
colorScheme: scheme,
extensions: [colors],
cardTheme: const CardThemeData(margin: EdgeInsets.symmetric(vertical: 4)),
chipTheme: ChipThemeData(side: BorderSide(color: scheme.outlineVariant)),
);
}
}
ColorScheme.fromSeed, seed rengini doğrudan kullanmıyor. Renkten bir ton (hue) ve doygunluk alıp tonal palette'ler üretiyor, sonra her rol için (primary, onPrimary, surface...) bu palette'lerden light ve dark temada farklı açıklıkta tonlar seçiyor. Varsayılan varyant DynamicSchemeVariant.tonalSpot. Testte iki şemanın renklerini ve primary ile üstündeki yazı arasındaki kontrastı yazdırdık:
light: primary #845416, onPrimary #FFFFFF, surface #FFF8F4, onSurface #211A14, primary/onPrimary contrast 6.4
dark: primary #FABA73, onPrimary #482900, surface #19120C, onSurface #EEE0D5, primary/onPrimary contrast 7.7
"Önce Tahmin Et"in cevabı burada: light temada primary #845416, dark temada açık bir ton olan #FABA73. Seed bir girdi, sonuç değil. Kazancı da çıktıda: her rol çifti okunabilir bir kontrastla geliyor (WCAG (Web Content Accessibility Guidelines; web erişilebilirlik yönergeleri) normal metin için en az 4.5 istiyor). Markanın tam rengi bir yerde birebir görünmek zorundaysa onu copyWith(primary: ...) ile ezebilirsin, ama o zaman kontrastı da sen kontrol edersin.
cardTheme ve chipTheme gibi component theme'leri, o component'i kullanan her yerin varsayılanını değiştirir. Kartların dış boşluğunu her Card'da ayrı ayrı vermek yerine burada bir kez veriyoruz. CSS'teki class tabanlı stil yerine, Angular Material'daki tema token'larına benzeyen bir yaklaşım.
Dark Mode: themeMode ve Platform Ayarı
MaterialApp iki tema alıyor ve hangisinin kullanılacağına themeMode karar veriyor:
MaterialApp(
title: 'Bookstore',
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.system,
localizationsDelegates: GlobalMaterialLocalizations.delegates,
supportedLocales: const [Locale('en'), Locale('tr')],
home: const HomeScreen(),
)
ThemeMode.system cihazın ayarını izliyor; light ve dark kullanıcı tercihini sabitlemek için. Testte platformun parlaklığını değiştirip uygulamayı açtık:
platform Brightness.dark -> theme Brightness.dark, price color #8FD8A6
platform Brightness.light -> theme Brightness.light, price color #1B6B3A
Aynı build'i web'e derleyip Playwright'ın Chromium'unda prefers-color-scheme light ve dark ile açtık ve app bar'ın arka planından bir pikselin rengini okuduk:
prefers-color-scheme: light -> app bar pixel #FFF8F4
prefers-color-scheme: dark -> app bar pixel #19120C
Değerler testteki surface renkleriyle aynı. Kullanıcı cihaz ayarını değiştirdiğinde uygulama yeniden başlamıyor; MaterialApp yeni temaya AnimatedTheme ile 200 ms içinde geçiyor ve Theme.of(context) çağıran her widget yeniden build ediliyor. Kullanıcıya uygulama içinde "açık / koyu / sistem" seçimi vermek istersen themeMode'u bir state'ten okursun; bunu state management yazısında yapacağız.
ThemeExtension ile Kendi Token'larımız
ColorScheme'in rolleri genel: primary, secondary, error, surface... Bookstore'un fiyat rengi ya da e-kitap etiketinin rengi gibi uygulamaya özgü değerler için yer yok. Bunları widget'lara sabit renk olarak yazmak yerine temaya bir ThemeExtension olarak ekliyoruz; design system'deki token'ların Flutter'daki karşılığı bu:
/// ColorScheme'de yeri olmayan, Bookstore'a özgü renk token'ları.
@immutable
class BookstoreColors extends ThemeExtension<BookstoreColors> {
const BookstoreColors({required this.price, required this.ebookBadge});
final Color price;
final Color ebookBadge;
static const light = BookstoreColors(
price: Color(0xFF1B6B3A),
ebookBadge: Color(0xFF7A4FB5),
);
static const dark = BookstoreColors(
price: Color(0xFF8FD8A6),
ebookBadge: Color(0xFFD3B8FF),
);
/// Extension'ı olmayan bir tema kullanılırsa (kendi MaterialApp'ini kuran
/// bir test ya da preview) varsayılan değerlere döner.
static BookstoreColors of(BuildContext context) {
final theme = Theme.of(context);
return theme.extension<BookstoreColors>() ??
(theme.brightness == Brightness.dark ? dark : light);
}
@override
BookstoreColors copyWith({Color? price, Color? ebookBadge}) =>
BookstoreColors(
price: price ?? this.price,
ebookBadge: ebookBadge ?? this.ebookBadge,
);
@override
BookstoreColors lerp(BookstoreColors? other, double t) {
if (other == null) return this;
return BookstoreColors(
price: Color.lerp(price, other.price, t)!,
ebookBadge: Color.lerp(ebookBadge, other.ebookBadge, t)!,
);
}
}
copyWith ve lerp zorunlu. lerp, tema değişirken iki değer arasındaki ara değeri üretiyor; AnimatedTheme geçiş boyunca bunu çağırıyor. Yanlış yazarsan dark moda geçişte fiyat rengi bir anda sıçrar. of'taki geri dönüş ise bir tuzaktan geldi: extension'ı eklemeden önce testlerin bir kısmı kendi MaterialApp'ini kuruyordu ve extension<BookstoreColors>()! null'a çarpıp beş testi düşürdü. Testte iki temanın fiyat renginin yüzeyle kontrastını ve ara değeri de kontrol ettik:
light: price on surface 6.2
dark: price on surface 11.1
lerp 0.5 price: #55A270
Kendi token'larının kontrastını bir testle sabitlemek, ileride biri rengi "biraz daha açık" yaptığında okunabilirliğin sessizce bozulmasını engelliyor.
Temayı Widget'larda Kullanmak
Widget'lar renkleri ve yazı stillerini sabit değerlerle değil, temadan okur. Katalog kartındaki fiyat satırı:
Text(
context.formatPrice(book.price),
style: textTheme.titleSmall?.copyWith(
color: BookstoreColors.of(context).price,
),
),
textTheme, Theme.of(context).textTheme'den geliyor. Material 3'ün yazı ölçeği display, headline, title, body ve label gruplarından oluşuyor, her biri large/medium/small. Stil değiştirirken yeni bir TextStyle oluşturmak yerine temadakini copyWith ile türet; böylece kullanıcının yazı boyutu ayarı ve dark mode'daki renk değişimi korunur. Theme.of(context) çağrısı widget'ı temaya bağımlı yapıyor, tema değişince yeniden build edilmesinin sebebi bu.
material_ui ve cupertino_ui: Ayrılan Paketler
Serinin başından beri package:flutter/material.dart yerine package:material_ui/material_ui.dart import ediyoruz. Nedeni, Flutter'ın tasarım kütüphanelerini SDK'dan ayırması. Flutter dokümanındaki geçiş rehberine göre takvim şöyle:
| Sürüm | Ne oldu |
|---|---|
| Flutter 3.44 | SDK içindeki Material ve Cupertino kodu donduruldu; yeni geliştirme yapılmıyor |
| Flutter 3.47 | material_ui ve cupertino_ui 1.0 pub.dev'de yayınlandı; geçiş isteğe bağlı |
| Flutter 3.50 (2026-11-11) | SDK içindeki kütüphaneler resmen deprecated; kaldırılma tarihi henüz verilmedi |
Seri 3.47 üzerinde yazılıyor ve şu anki stable sürüm 3.50.0; 3.50 farklarını serinin son yazısında topluyoruz. Paketler artık SDK'dan bağımsız sürümleniyor: material_ui, 3.47'deki 1.0'dan bu yana 1.11.0'a geldi. Bir Material düzeltmesi için Flutter'ı yükseltmen gerekmiyor, paketin sürümünü yükseltmen yetiyor.
Var olan bir projeyi geçirmek için dart fix --apply --code=migrate_design_widgets import'ları değiştiriyor. İkinci yazıda gördüğümüz gibi kodu adıyla vermek şart; yalnız dart fix --dry-run "Nothing to fix!" diyor. Geçişte iki ayrıntı var. Birincisi localization: flutter_localizations'daki GlobalMaterialLocalizations yerine material_ui'deki aynı adlı class kullanılıyor ve GlobalMaterialLocalizations.delegates Material, Cupertino ve widgets delegate'lerinin üçünü birden veriyor. İkincisi henüz geçmemiş paketler: bir bağımlılığın hâlâ eski material.dart'ı kullanıyorsa, MaterialApp.builder'da MaterialUiCompatibilityBridge ile sarmalamak, o paketin widget'larının Theme.of ve MaterialLocalizations.of çağrılarının çalışmasını sağlıyor. Köprü tip uyuşmazlığını çözmüyor: public API'sinde SDK'nın ColorScheme ya da TextTheme tipini alan bir paket önce kendisi geçmeli.
Yeni bir projede karar basit: material_ui ve gerekiyorsa cupertino_ui ile başla. Bookstore'da cupertino_ui şimdilik yalnız testte kullanıldığı için dev dependency.
Adaptive Widget'lar ve Cupertino
Material widget'larının bir kısmının .adaptive constructor'ı var: iOS ve macOS'ta platformun görünümüne, diğerlerinde Material görünümüne geçiyorlar. LoadingView'daki göstergeyi CircularProgressIndicator.adaptive() yaptık; iOS'ta Cupertino'nun dönen çizgilerini gösteriyor. Switch.adaptive'i iki platformda açtık:
TargetPlatform.android -> 1 Switch, 0 CupertinoSwitch, size Size(60.0, 48.0)
TargetPlatform.iOS -> 1 Switch, 0 CupertinoSwitch, size Size(60.0, 48.0)
İki platformda da ağaçta tek bir Switch var, CupertinoSwitch oluşturulmuyor; dış boyut (dokunma alanı dahil) da aynı. Fark çizimde: material_ui kaynak koduna göre adaptive Switch, platform iOS ya da macOS olduğunda Cupertino ölçülerine (51 x 31 track, gölgeli büyük thumb) ve Cupertino renk kurallarına geçiyor. Platformu ThemeData.platform'dan okuyor; testte debugDefaultTargetPlatformOverride ile değiştirmemizin nedeni bu.
Ne zaman tamamen Cupertino: uygulama yalnız iOS'a gidiyorsa ya da iOS kullanıcılarının alıştığı davranış (sağdan kaydırarak geri gitme, alttan açılan action sheet, Cupertino tarih seçici) ürün için önemliyse. Bookstore iki platforma da aynı markayla çıkıyor; Material 3 taban, platforma özgü davranış gereken yerde adaptive widget. İki ayrı widget ağacı yazmak, her ekranı iki kez test etmek demek.
Türkçe Locale, flutter_localizations ve intl
MaterialApp'e verdiğimiz localizationsDelegates ve supportedLocales, Material'ın kendi metinlerini (Tamam, İptal, tarih seçicideki ay adları, ekran okuyucu etiketleri) cihazın diline göre seçiyor. Bizim metinlerimiz ve para ile tarih biçimi ise intl paketinden. Fiyat ve tarihi locale'e göre biçimleyen küçük bir extension yazdık:
/// Parayı ve tarihi uygulamanın gösterildiği locale'e göre biçimler.
extension LocaleFormatting on BuildContext {
String _locale() => Localizations.localeOf(this).toLanguageTag();
String formatPrice(Money price) => NumberFormat.currency(
locale: _locale(),
name: 'TRY',
symbol: '₺',
).format(price.kurus / 100);
String formatDate(DateTime date) => DateFormat.yMMMMd(_locale()).format(date);
}
en: OK / Cancel, November 15, 2026, ₺489.00
tr: Tamam / İptal, 15 Kasım 2026, ₺489,00
Ondalık ayırıcı ve ay adı locale'den geliyor. Money içeride kuruş olarak int tutuyor; double'a çevirme yalnız gösterim anında ve iki basamak için yapılıyor, hesap hep int ile. Uygulamanın kendi metinlerini ("Add to cart", "Try again") çevirmek için Flutter'ın ARB (Application Resource Bundle; çevirileri tutan JSON tabanlı dosya) dosyalarından kod üreten gen-l10n aracı var; Bookstore'un arayüz metinleri bu seride İngilizce kalıyor. Dil ayarının API tarafındaki karşılığı ASP.NET Core Web API serisinin "Web API'de Localization: Accept-Language, RequestLocalization, Yerelleştirilmiş Validation ve ProblemDetails Mesajları" yazısında.
Üretimde Dikkat
Her ekranı iki temada ve büyük yazıyla aç. Dark mode'da en sık görülen hata, temadan okunmayan sabit bir rengin (ör. Colors.black ile yazılmış bir metin) koyu zeminde kaybolması. flutter analyze bunu yakalamaz; bir widget testinde platformBrightnessTestValue ile dark temayı açmak yakalar.
Renkleri tek yerden yönet. Color(0xFF...) ifadesi yalnız lib/ui/core/themes altında geçmeli. Code review'da başka bir dosyada sabit renk görürsen ya ColorScheme'deki bir role ya da bir ThemeExtension alanına taşı.
Paket sürümlerini takip et. material_ui ayrı sürümlendiği için düzeltmeler de ayrı geliyor; 1.11.0'ın değişiklik listesinde Switch.adaptive'in ilk çizimdeki bir hatası ve RefreshIndicator'ın fare davranışının dokümantasyonu var. flutter pub outdated ile düzenli kontrol et.
Sık Düşülen Tuzaklar
Birinci tuzak, seed rengini primary sanmak. fromSeed bir palet üretir; markanın tam rengine ihtiyacın varsa açıkça ez ve kontrastı ölç.
İkinci tuzak, ThemeExtension'ın lerp'ünü return this; diye geçiştirmek. Derlenir, ama tema geçişinde değer animasyonsuz sıçrar.
Üçüncü tuzak, yalnız theme verip darkTheme'i unutmak. Bu durumda ThemeMode.system dark modda da light temayı kullanır; kullanıcı ayarı görmezden gelinmiş olur.
Dördüncü tuzak, NumberFormat'ı locale vermeden kullanmak. Flutter, Intl.defaultLocale'i cihaz diline ayarlamıyor: locale: Locale('tr') ile açılan bir uygulamada denediğimizde Intl.defaultLocale null kaldı, NumberFormat en_US kullandı ve fiyat ₺489.00 çıktı.
Küçük Bir Deneme
★☆☆ AppTheme'de fromSeed'e dynamicSchemeVariant: DynamicSchemeVariant.fidelity ver ve theme_test.dart'ı çalıştır. Light temadaki primary seed rengine yaklaştı mı, kontrast ne oldu?
★★☆ BookstoreColors'a discount adında bir renk ekle (indirimli fiyat için) ve kontrast testini bu renk için de yaz. Light ve dark değerleri 4.5'in üstünde kalacak şekilde seç.
★★★ Tarihi DateFormat.yMMMMd yerine göreli biçimde ("2 gün önce" / "2 days ago") gösteren bir formatRelative yaz. Saat dilimi farkını test edebilmek için şu anki zamanı parametre olarak al (dördüncü yazıdaki Clock yaklaşımı).
Bir sonraki yazıda kullanıcıdan veri alıyoruz: Form ve TextFormField ile giriş ve teslimat adresi formu, validation, FocusNode ile alanlar arası geçiş, klavyenin içeriği örtmesi ve sunucudan dönen ProblemDetails hatalarını alanlara eşlemek.