Mert Özen Her Satırda Daha İleri
Frontend Flutter ile Mobil — #12

Flutter'da Navigation: Navigator, go_router ile Declarative Routing, Deep Link ve Redirect ile Auth Guard

Mert Özen 15 Kas 2026 10 dk 5 görüntülenme
Flutter'da Navigation: Navigator, go_router ile Declarative Routing, Deep Link ve Redirect ile Auth Guard

go_router'da context.go URL'ye gidip stack'i URL'nin hiyerarşisinden yeniden kurar, context.push ise mevcut stack'in üstüne ekler ve sonuç döndürebilir. Bookstore'a StatefulShellRoute ile her sekmesi kendi stack'ini tutan alt menüyü, /catalog/books/:isbn deep link'ini, refreshListenable ile kendiliğinden yeniden çalışan redirect auth guard'ını ve PopScope ile form kaybını önlemeyi gerçek test ve Chromium çıktılarıyla ekliyoruz.

Sürüm notu: Bu yazıdaki örnekler Flutter 3.47.8 (Dart 3.13.7), material_ui 1.11.0, provider 6.1.5+1 ve go_router 18.1.4 ile denenmiştir. Çıktılar flutter test koşularından ve web build'inin Playwright'ın headless Chromium'unda açılmasından; Android App Links ve iOS Universal Links kurulumu cihaz, alan adı ve imzalama anahtarı gerektirdiği için çalıştırılmadı, dokümana göre anlatılıyor.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) mobil uygulamasında kullanıcı Dune'un detay sayfasında. Giriş ekranını açmanın iki yolu var:

router.go('/sign-in');
// ya da
router.push('/sign-in');

Her ikisinde de ekranda giriş formu görünüyor. Kullanıcı geri tuşuna bastığında hangisinde Dune'a döner? Birinde döner, diğerinde geri gidecek bir sayfa bile yoktur. Hangisi hangisi, ve bu fark neden önemli?

Flutter'ın temel navigation aracı Navigator: ekranları bir stack'te tutar, push üste bir route ekler, pop en üsttekini kaldırır. Şimdiye kadar detay ve sepet ekranlarını Navigator.push ile açıyorduk. push bir Future döner ve bu Future, açılan route pop ile kapandığında verilen değerle tamamlanır. Diyaloglar ve bottom sheet'ler de birer route; detay sayfasında "Add to cart"a basınca adet seçtiren sheet tam olarak bunu kullanıyor:

Future<void> _addToCart(Book book) async {
  // Bottom sheet de bir route: pop(context, 2) bu Future'ı tamamlar
  final quantity = await showModalBottomSheet<int>(
    context: context,
    builder: (context) => Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        for (var n = 1; n <= 3; n++)
          ListTile(
            title: Text('$n'),
            onTap: () => Navigator.of(context).pop(n),
          ),
      ],
    ),
  );
  if (quantity == null || !mounted) return; // kapatıldı
  final cart = context.read<CartViewModel>();
  await cart.setQuantity.execute((
    book: book,
    quantity: cart.quantityOf(book) + quantity,
  ));
}

Kullanıcı sheet'i dışına dokunarak kapatırsa sonuç null; bu yüzden tip int? olarak ele alınıyor. await'ten sonra altıncı yazıdaki mounted kontrolü yine burada.

Ekranlar arası geçiş için ise Navigator.push'un iki sınırı var. Birincisi URL: bir bildirimdeki ya da e-postadaki bağlantıdan uygulamanın belli bir sayfasına gelmek, push zincirleriyle kurulmuş bir yapıda zor. İkincisi state: "giriş yapmamışsa siparişlere gitmesin" gibi kuralları her push çağrısından önce ayrı ayrı yazmak gerekir. Flutter dokümanı bu yüzden çoğu uygulama için bir routing paketi öneriyor; mimari rehberindeki önerilen paket go_router.

go_router ile Declarative Routing

go_router'da ekranlar bir URL ağacı olarak tanımlanır; uygulamanın hangi ekranda olduğunu URL belirler. Angular'daki route config'e çok benziyor. Bookstore'un router'ının ilk yarısı:

GoRouter createRouter({
  required AuthRepository auth,
  String? initialLocation,
}) => GoRouter(
  initialLocation: initialLocation ?? Routes.catalog,
  // Giriş ve çıkış redirect'i yeniden çalıştırır
  refreshListenable: auth,
  redirect: (context, state) =>
      authRedirect(state.uri, signedIn: auth.isSignedIn),
  routes: [
    GoRoute(path: '/', redirect: (_, _) => Routes.catalog),
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) =>
          ScaffoldWithNavBar(navigationShell: navigationShell),
      branches: [
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: Routes.catalog,
              builder: (context, state) => const HomeScreen(),
              routes: [
                GoRoute(
                  path: 'books/:isbn',
                  builder: (context, state) =>
                      BookDetailsRoute(isbn: state.pathParameters['isbn']!),
                ),
              ],
            ),
          ],
        ),

Route'lar /catalog, /catalog/books/:isbn, /cart, /cart/address, /orders ve /sign-in. Path'ler Routes class'ında sabit; kodun içinde string ile URL kurulmuyor. MaterialApp artık MaterialApp.router(routerConfig: _router). Router, BookstoreApp'in State'inde bir kez oluşturuluyor: build içinde oluşturulan bir GoRouter, uygulama her rebuild olduğunda navigation'ı başa sarar.

Detay sayfası artık bir Book object'i değil, URL'deki ISBN'i (kitapların uluslararası kimlik numarası) alıyor ve kitabı BookDetailsViewModel ile repository'den yüklüyor. Böylece sayfa yalnız katalogdan değil, dışarıdan gelen bir bağlantıyla da açılabiliyor. Testte uygulamayı doğrudan bu URL ile başlattık:

location: /catalog/books/9780441172719, title: true, can pop: true
after back: /catalog

Uygulama hiç katalogdan geçmeden detay sayfasında açıldı, ama geri gidilebiliyor ve geri gidince katalog geliyor. go_router, URL'nin hiyerarşisinden stack'i kendisi kurdu: /catalog/books/..., /catalog'un child route'u olduğu için altına katalog sayfası kondu.

StatefulShellRoute: Her Sekmenin Kendi Stack'i

Alt menüde üç sekme var: Catalog, Cart, Orders. Kullanıcı katalogda bir kitabın detayına girip sepete geçerse ve sonra kataloga dönerse, detay sayfasını bıraktığı yerde bulmayı bekler. StatefulShellRoute.indexedStack her branch için ayrı bir Navigator tutuyor ve sekmeleri bir IndexedStack içinde canlı bırakıyor. Kabuk widget'ı:

/// Üç sekmenin etrafındaki alt menü; her sekme kendi stack'ini tutar.
class ScaffoldWithNavBar extends StatelessWidget {
  const ScaffoldWithNavBar({super.key, required this.navigationShell});

  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: NavigationBar(
        selectedIndex: navigationShell.currentIndex,
        // Seçili sekmeye tekrar dokunmak o sekmenin ilk sayfasına döner
        onDestinationSelected: (index) => navigationShell.goBranch(
          index,
          initialLocation: index == navigationShell.currentIndex,
        ),
tap Cart tab -> /cart
tap Catalog tab -> /catalog/books/9780441172719
tap Catalog tab again -> /catalog

Katalog sekmesi detay sayfasını hatırladı; seçili sekmeye ikinci dokunuş ise sekmenin başına döndü, iOS ve Android kullanıcılarının alıştığı davranış. Sepet sekmesinin ikonundaki rozet bir önceki yazıdaki Selector ile yalnız kitap sayısı değiştiğinde yeniden build ediliyor. Sekmeler canlı tutulduğu için katalogdaki CatalogViewModel sekme değişince kaybolmuyor; kaydırma konumu ve yüklenmiş sayfalar yerinde kalıyor.

redirect ile Auth Guard

Siparişler ve teslimat adresi giriş yapmış kullanıcı içindir. go_router'da bunun yeri redirect: her navigation'dan önce çalışır, null dönerse geçişe izin verir, bir path dönerse oraya yönlendirir. Kuralı widget'tan bağımsız, düz bir fonksiyon olarak yazdık:

/// Auth guard; unit test yazılabilsin diye düz bir fonksiyon.
String? authRedirect(Uri uri, {required bool signedIn}) {
  final path = uri.path;
  final needsSignIn = path.startsWith(Routes.orders) || path == Routes.address;
  if (!signedIn && needsSignIn) {
    return Uri(
      path: Routes.signIn,
      queryParameters: {'from': uri.toString()},
    ).toString();
  }
  if (signedIn && path == Routes.signIn) {
    final from = uri.queryParameters['from'];
    // Yalnız uygulama içi bir path: "/orders" evet, "//other.example" hayır
    final isLocal =
        from != null && from.startsWith('/') && !from.startsWith('//');
    return isLocal ? from : Routes.catalog;
  }
  return null;
}
/orders (signed in: false) -> /sign-in?from=%2Forders
/cart/address (signed in: false) -> /sign-in?from=%2Fcart%2Faddress
/catalog/books/9780441172719 (signed in: false) -> stays
/sign-in?from=%2Forders (signed in: true) -> /orders
/sign-in?from=%2F%2Fother.example (signed in: true) -> /catalog

Angular serisindeki "Angular Guard'lar ve Lazy Loading: CanActivate, CanDeactivate, CanLoad ve Resolve" yazısının karşılığı: redirect bir CanActivate gibi çalışıyor, bir UrlTree döndürmek yerine path döndürüyor. Fark, yeniden değerlendirmenin otomatik olması: refreshListenable: auth verdiğimiz için AuthRepository bildirim yaptığında (giriş ya da çıkış) router redirect'i mevcut URL için tekrar çalıştırıyor. Giriş formu başarıyla gönderildiğinde kimse go çağırmıyor; kullanıcı, redirect sayesinde geldiği yere dönüyor:

signed out, go /orders -> /sign-in?from=%2Forders
after sign-in -> /orders, BK-1042 shown: true

from parametresindeki kontrol bir güvenlik önlemi. Bu parametre dışarıdan gelen bir bağlantıyla doldurulabilir; kontrol olmadan giriş sonrası kullanıcı saldırganın seçtiği bir yere yönlendirilebilir (open redirect). Web Güvenliği serisinde anlatılan zafiyetin mobil karşılığı. go_router 18'de bir de onEnter callback'i var: her navigation'da redirect'lerden önce çalışıyor ve geçişi engelleyebiliyor; paketin dokümanı redirect'i "legacy" diye anıyor ama desteklemeye devam ediyor.

go mu push mu? Sonuç ve Form Kaybı

"Önce Tahmin Et"in cevabı testte:

go(/sign-in): can pop false
push(/sign-in): can pop true
after back: /catalog/books/9780441172719

go URL'ye gider ve stack'i o URL'nin hiyerarşisinden yeniden kurar. /sign-in en üst seviyede bir route olduğu için altında hiçbir şey yok; geri tuşu uygulamadan çıkar. push ise mevcut stack'in üstüne ekler; geri tuşu Dune'a döner. Kural: bir yerden başka bir yere "gitmek" (sekme değiştirmek, bir bağlantıyı açmak) için go, bir iş yapıp geri dönmek (giriş, adres seçmek, onay) için push. Sonuç döndürmek de yalnız push ile mümkün: sepet ekranı teslimat adresi formunu context.push<DeliveryAddress>(Routes.address) ile açıyor, form başarılı olunca context.pop(address) çağırıyor ve adres sepet ekranına dönüyor.

Formdaki bir sorun daha: kullanıcı adresin yarısını yazmışken yanlışlıkla geri tuşuna ya da kenardan kaydırma hareketine basarsa yazdıkları kaybolur. PopScope bunu engelliyor:

// Kullanıcı bir şey yazdıysa geri tuşu ve geri hareketi önce sorar
return PopScope<Object?>(
  canPop: !_dirty,
  onPopInvokedWithResult: (didPop, result) async {
    if (didPop) return;
    if (await _confirmDiscard() && context.mounted) {
      Navigator.of(context).pop();
    }
  },
back with text: dialog 1
push result: DeliveryAddress, location /cart

_dirty, controller'lardan herhangi biri dolu olduğunda true. Geri tuşu "Discard this address?" diyaloğunu açtı; formu doldurup gönderdiğimizde ise context.pop(address) PopScope'a takılmadı, çünkü canPop yalnız sistem geri tuşunu ve geri hareketini etkiliyor, kodun açıkça çağırdığı pop'u değil. Eski WillPopScope yerine PopScope gelmesinin nedeni Android'in predictive back (geri hareketi sırasında önceki ekranın önizlemesi) özelliği: sistemin, hareket başlamadan geri gidilip gidilemeyeceğini bilmesi gerekiyor.

Deep link, bir web adresine (ör. https://bookstore.example.com/catalog/books/9780441172719) dokunulduğunda tarayıcı yerine uygulamanın ilgili sayfasının açılması. Router tarafı hazır: go_router gelen URL'nin path'ini alıp yukarıdaki route'larla eşliyor. Flutter 3.27'den beri Flutter'ın kendi deep link işleyicisi varsayılan olarak açık; başka bir deep link paketi kullanıyorsan Android'de flutter_deeplinking_enabled, iOS'ta FlutterDeepLinkingEnabled ile kapatman gerekiyor. Platform tarafında iki iş var, ikisi de alan adına sahip olmayı gerektiriyor:

  • Android App Links: AndroidManifest.xml'deki activity'ye android:autoVerify="true" olan, VIEW aksiyonu, DEFAULT ve BROWSABLE kategorileri ve alan adını taşıyan bir intent-filter; alan adında /.well-known/assetlinks.json dosyası, içinde uygulamanın paket adı ve imzalama sertifikasının parmak izi (SHA-256 ile hesaplanan özet değer).
  • iOS Universal Links: Xcode'da Associated Domains yeteneği ve applinks: ile başlayan alan adı; alan adında /.well-known/apple-app-site-association dosyası, içinde uygulamanın team ve bundle ID'si ve hangi path'lerin uygulamada açılacağı.

Bu kurulum bu yazıda çalıştırılmadı: gerçek bir alan adı, imzalanmış bir build ve cihaz gerekiyor; imzalama yayınlama yazısının konusu. Router'ın aynı URL'leri doğru işlediğini web build'iyle doğruladık. Web'de go_router varsayılan olarak URL'nin #'tan sonraki kısmını kullanıyor; iki bağlantıyı Chromium'da açtık:

#/catalog/books/9780441172719 -> #/catalog/books/9780441172719, shows: Dune, console errors: 0
#/orders -> #/sign-in?from=/orders, shows: Sign in, console errors: 0

Üretimde Dikkat

URL'ler bir API sözleşmesi gibidir. Bir bildirime, e-postaya ya da paylaşılmış bir mesaja giren link aylarca kullanılır. Path'i değiştirirsen eskisini bir redirect ile yenisine yönlendir; aksi halde kullanıcı go_router'ın hata sayfasını görür. Hata sayfasını da errorBuilder ile kendi tasarımına çevir.

Deep link'le gelen her parametreyi doğrula. ISBN'i olmayan bir kitap, başka kullanıcıya ait bir sipariş numarası ya da from gibi bir yönlendirme adresi gelebilir. Detay sayfası bu yüzden "This book could not be found." durumunu gösterebiliyor; sipariş gibi kişisel verilerde yetki kontrolü her zaman sunucuda.

Navigation'ı test et. Router'ın davranışı widget testinde, emulator olmadan ölçülebiliyor: GoRouter.of(context) ile router'a ulaşıp go, push, pop çağırmak ve routerDelegate.currentConfiguration.uri'yi okumak yetiyor. Bir dikkat noktası: widget testinin sahte saatinde push'un döndürdüğü Future'ı doğrudan await edersen test kilitlenir, çünkü Future ancak sonraki bir pump'ta tamamlanır; ilk denememizde testler bu yüzden takıldı. unawaited ile başlatıp sonucu then ile bir değişkene yazmak gerekiyor.

Sık Düşülen Tuzaklar

Birinci tuzak, her yerde go kullanmak. Sonuç beklenen bir ekranı go ile açarsan sonuç hiç dönmez ve geri tuşu beklenmedik bir yere gider.

İkinci tuzak, GoRouter'ı build içinde oluşturmak. Tema değişimi gibi sıradan bir rebuild'de bütün navigation sıfırlanır.

Üçüncü tuzak, redirect'te sonsuz döngü kurmak. /sign-in'i de korunan sayfalara eklersen redirect kendini çağırır; go_router bunu "redirect loop detected" hatasıyla durdurur; ayrıca bir navigation'daki redirect sayısı varsayılan olarak 5 ile sınırlı.

Dördüncü tuzak, ekranlara URL'den gelmesi gereken veriyi extra ile object olarak geçmek. extra deep link'te yoktur ve web'de sayfa yenilenince kaybolur; sayfanın ihtiyaç duyduğu kimlik path'te olmalı, veri repository'den yüklenmeli.

Küçük Bir Deneme

★☆☆ authRedirect'e giriş yapmış kullanıcının /sign-in'e from olmadan geldiği durum için bir test satırı ekle. Sonuç ne?

★★☆ /catalog route'una ?format=ebook query parametresini ekle: state.uri.queryParameters'tan okunsun ve CatalogViewModel'e filtre olarak geçsin. Web build'inde #/catalog?format=ebook ile açıldığını doğrula.

★★★ Sepet ekranındaki "Checkout"a giriş yapmamış bir kullanıcı basarsa ne oluyor? push edilen adres sayfası redirect ile giriş sayfasına dönüşüyor; giriş sonrası kullanıcının adres formuna ve oradan sepete sonuçla dönebilmesi için akışı tasarla ve bir router testiyle doğrula.

Bir sonraki yazıda uygulamayı gerçek veriye bağlıyoruz: http ve dio, JSON serialization, Result ile hata yönetimi, ProblemDetails'i parse etmek ve ASP.NET Core Bookstore API'den sayfalı kitap listesi ile sipariş oluşturmak.