Flutter’da Provider ile BottomNavigationBar Mimarisi

Flutter’da bir şeyi yapmanın birçok yolu var. Bu bazen işlerimizi kolaylaştırsa da bazen karmaşıklığa neden olabiliyor.

En çok kafa karışıklığına yol açan bir tanesi de BottomNavigationBar kullanımı. Hatta bu zamana kadar, daha işlevsel olduğunu düşündüğüm için Cupertino kütüphanesinden CupertinoTabBar kullanıyordum. Artık BottomNavigationBar’ın nasıl ilginç olabileceğini keşfedeceğiz.

İşi ilginç yapan kısım şu; sayfalar arası dolaşırken gezinme geçmişini kaybetmeyeceğiz. Şuradaki gibi: 

Bu yazı başlangıç düzeyi için uygun değildir. Öncesinde şu konuları bilmeniz gerekir:

İlk başta da dediğim gibi, Flutter’da bir şeyi yapabiliyor olmanın bir çok alternatifi vardır. Bu konuyla alakalı daha iyi bir yol biliyorsanız lütfen benimle paylaşın.

Gün sonunda proje dosyalarımız şu şekilde olacak:

Logic

İlk başta tüm navigasyon ekranlarımızın tüm bilgilerini saklayabilecek bir Screen modeli oluşturmamız gerekiyor. screen.dart isimli bu amaçla oluşturuyoruz.

import 'package:flutter/material.dart';

/// [Screen] Ekran oluşturrurken gereken tüm bilgileri tutacak
class Screen {
  /// BottomNavigationBar'da gösterilecek olan başlık
  final String title;

  /// BottomNavigationBar'da gösterilecek olan ikon
  final IconData icon;

  /// Ekranda akacak olan WidgetTree tutucusu
  final Widget child;

  /// İlgili ekranın devamı için isimlendirilmiş rota oluşturucu [Navigator]
  final RouteFactory onGenerateRoute;

  /// İlk rotanın [onGenerateRoute] içinde implemente edilmesi gerekiyor
  final String initialRoute;

  /// İlgili ekranın [NavigatorState]'i için key gerekiyor
  final GlobalKey<NavigatorState> navigatorState;

  /// ISTEGE BAGLI: İlgili ekranın en üstüne dönerken hareket animasyonunu değiştirmek için gerekli
  final ScrollController scrollController;

  Screen({
    @required this.title,
    @required this.icon,
    @required this.child,
    @required this.onGenerateRoute,
    @required this.initialRoute,
    @required this.navigatorState,
    this.scrollController,
  });
}

Screen modeli sayesinde navigasyon sekmeleri ile çalışırken işimizi çok kolaylaştırmış olacağız. Buradaki child nesnesi sayesinde Scaffold inşa ediyorken, navigatorState ile çoklu contextler ile çalışabileceğiz.

Screen implementasyonu

navigation_provider.dart isimli bir dosya oluşturup aşağıdaki kodları takip edelim.

Her navigasyon ekranının index ile referanslandırılması gereklidir. Bu sebeple class üstünde ekran indexlerimiz için aşağıdaki sabitleri tanımlayacağız. Bunları enum yapısı veya class içerisinde static const olarak da tanımlayabilirdik ancak benim aklıma bu yattı.

const FIRST_SCREEN = 0;
const SECOND_SCREEN = 1;
const THIRD_SCREEN = 2;
const FOURTH_SCREEN = 3;

Şimdi ise Screen sınıfından türeteceğimiz ekranlarımızı oluşturalım. Oluşturacağımız ekranlardan, FirstScreen, SecondScreen, ThirdScreen ve FourthScreen navigasyonumuzun sekmeleri olacakken PushedScreen ve SimpleScreen ise navigatorState‘i test etmemize ve örneğimizi pekiştirmeye yarayacak.

first_screen.dart

class FirstScreen extends StatelessWidget {
  static const route = '/first';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('First Screen')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            RaisedButton(
              onPressed: () {
                Navigator.of(
                  context,
                  // BottomNavigationBar ile diğer ekrana git
                  rootNavigator: false,
                ).pushNamed(PushedScreen.route);
              },
              child: Text('BottomNavigationBar ile sayfaya git'),
            ),
            RaisedButton(
              onPressed: () {
                Navigator.of(
                  context,
                  // BottomNavigationBar olmadan diğer ekrana git (Kök dizin)
                  rootNavigator: true,
                ).pushNamed(PushedScreen.route);
              },
              child: Text('BottomNavigationBar olmadan sayfaya git'),
            ),
          ],
        ),
      ),
    );
  }
}

second_screen.dart

class SecondScreen extends StatelessWidget {
  static const route = '/second';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Second Screen')),
      body: ListView.builder(
        controller: NavigationProvider.of(context)
            .screens[SECOND_SCREEN]
            .scrollController,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('Item: $index'),
          );
        },
      ),
    );
  }
}

third_screen.dart

class ThirdScreen extends StatelessWidget {
  static const route = '/third';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Third Screen')),
      body: Center(
        child: FlutterLogo(
          size: 128,
        ),
      ),
    );
  }
}

fourth_screen.dart

class FourthScreen extends StatelessWidget {
  static const route = '/extra';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Extra Screen')),
      body: Center(
        child: RaisedButton(
          onPressed: () {
            // Push with bottom navigation visible
            Navigator.of(
              context,
              rootNavigator: true,
            ).pushNamed(SimpleScreen.route);
          },
          child: Text('Go to Simple Page'),
        ),
      ),
    );
  }
}

pushed_screen.dart

class PushedScreen extends StatelessWidget {
  static const route = '/first/pushed';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Pushed Screen')),
      body: Center(
        child: Text('Hello world!'),
      ),
    );
  }
}

simple_screen.dart

class SimpleScreen extends StatelessWidget {
  static const route = '/extra/simple';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Simple Screen"),
      ),
    );
  }
}

Yukarıda belirttiğim gibi, PushedScreen ve SimpleScreen‘ın kendi sabiti yoktur çünkü BottomNavigationBar‘da gösterilmeyecek.

Navigatin Provider

İşleri ilginçleştirmeye başlıyoruz. Bu işi yaparken, state management’i Provider paketi ile çok kolay hale getireceğiz.

Provider.of<NavigationProvider>(context, listen: false) (varsayılan olarak listen true değeri alır) ile tüm işi hızlıca halledeceğiz. navigation_provider.dart dosyasına geri dönüp devam edelim.

NavigationProvider

class NavigationProvider extends ChangeNotifier {
  /// [NavigationProvider] edinmek için shortcode
  static NavigationProvider of(BuildContext context) =>
      Provider.of<NavigationProvider>(context, listen: false);

  // Açılış sayfası
  int _currentScreenIndex = FIRST_SCREEN;
  int get currentTabIndex => _currentScreenIndex;

  Route<dynamic> onGenerateRoute(RouteSettings settings) {
    print('Oluşturulan rota: ${settings.name}');
    switch (settings.name) {
      case PushedScreen.route:
        return MaterialPageRoute(builder: (_) => PushedScreen());
      case SimpleScreen.route:
        return MaterialPageRoute(builder: (_) => SimpleScreen());
      default:
        return MaterialPageRoute(builder: (_) => Root());
    }
  }

  final Map<int, Screen> _screens = {
    FIRST_SCREEN: Screen(
      title: 'First',
      icon: Icons.home,
      child: FirstScreen(),
      initialRoute: FirstScreen.route,
      navigatorState: GlobalKey<NavigatorState>(),
      onGenerateRoute: (settings) {
        print('Oluşturulan rota: ${settings.name}');
        switch (settings.name) {
          case PushedScreen.route:
            return MaterialPageRoute(builder: (_) => PushedScreen());
          default:
            return MaterialPageRoute(builder: (_) => FirstScreen());
        }
      },
      scrollController: ScrollController(),
    ),
    SECOND_SCREEN: Screen(
      title: 'Second',
      icon: Icons.search,
      child: SecondScreen(),
      initialRoute: SecondScreen.route,
      navigatorState: GlobalKey<NavigatorState>(),
      onGenerateRoute: (settings) {
        print('Oluşturulan route: ${settings.name}');
        switch (settings.name) {
          default:
            return MaterialPageRoute(builder: (_) => SecondScreen());
        }
      },
      scrollController: ScrollController(),
    ),
    THIRD_SCREEN: Screen(
      title: 'Third',
      icon: Icons.favorite,
      child: ThirdScreen(),
      initialRoute: ThirdScreen.route,
      navigatorState: GlobalKey<NavigatorState>(),
      onGenerateRoute: (settings) {
        print('Oluşturulan route: ${settings.name}');
        switch (settings.name) {
          default:
            return MaterialPageRoute(builder: (_) => ThirdScreen());
        }
      },
      scrollController: ScrollController(),
    ),
    FOURTH_SCREEN: Screen(
      title: 'Fourth',
      icon: Icons.message,
      child: FourthScreen(),
      initialRoute: FourthScreen.route,
      navigatorState: GlobalKey<NavigatorState>(),
      onGenerateRoute: (settings) {
        print('Oluşturulan route: ${settings.name}');
        switch (settings.name) {
          case SimpleScreen.route:
            return MaterialPageRoute(builder: (_) => SimpleScreen());
          default:
            return MaterialPageRoute(builder: (_) => FourthScreen());
        }
      },
      scrollController: ScrollController(),
    ),
  };

  List<Screen> get screens => _screens.values.toList();

  Screen get currentScreen => _screens[_currentScreenIndex];

//...

İşler karmaşıklaşmadan önce yukarıdaki kod parçacığını anlamaya çalışalım.

NavigationProvider sınıfımızın üstünde, sayfaların gerekli index değerlerini oluşturabilmemiz için değişkenler tanımlamıştık. Açılış sayfamızı tanımlamak ve mevcut ekran değişikliklerini uygulayabilmek adına, _currentScreenIndex isimli bir değişken oluşturduk ve ilk değer olarak FIRST_SCREEN (anasayfa) verdik. Gizli değişken olduğu için getter kullandık.

NavigationProvider’ın içinde saklaması gereken bir diğer şey ise tüm ekranlarımız. Ekranlarımızı saklamanın en iyi yolu Map<int, Screen> olduğunu varsayarak Map türünde _screen isimli bir değişken oluşturduk.

Daha da açıklamak gerekirse, tam da burada Screen isimli model sınıfımızı kullanmış olduk. Yani aslında, Map<int, Screen>‘ın int olan kısmı en üstte tanımladığımız sayfa indexlerini alıyorken, Screen kısmı için, yukarıda oluşturduğumuz ekran sayfalarını Screen modelinden türetmiş oluyoruz.

Peki 2. satırda bulunan [NavigationProvider] edinmek için shortcode dediğimiz kısım ne işe yarıyor?

Yukarıda da bahsettiğim gibi, Provider erişimine ihtiyaç duyduğumuz zaman aşağıdaki tanımlamayı kullanıyoruz:

Provider.of<NavigationProvider>(context, listen: false);

Ama artık biz, 3. satırdaki kod sayesinde artık işleri daha da kolaylaştırarak, tıpkı InheritedWidget‘larda olduğu gibi şu şekilde çağırabiliriz:

NavigationProvider.of(context)

Devam edelim. Navigasyon sekmelerimiz arasında geçiş yapabilmemiz için bir metot tanımlamamız gerekiyor. Bu metodun aldığı tab değeri ile currentTabIndex değeri aynı ise, (eğer ekranın en üstünde değilse) ekranın en üst kısmına dönme işlemlerini yaparken, eğer farklı ise ekranlar arası geçiş işlemlerini yapıp dinleyicimiz olan Provider’ı haberdar edeceğiz.

   void setTab(int tab) {
    if (tab == currentTabIndex) {
      // TODO: Ekranın en üstüne dönme işlemleri: _scrollToTopOfPage();
    } else {
      _currentScreenIndex = tab;
      notifyListeners();
    }
  }
//...

Root Widget

Bildiğiniz gibi isimlendirilmiş rotalarda kök dizin olarak “/” belirtiriz. Kök dizini, Consumer ile sarmalayarak değişiklikleri dinleyebiliriz.

root.dart

class Root extends StatelessWidget {
  static const route = '/';

  @override
  Widget build(BuildContext context) {
    return Consumer<NavigationProvider>(
      builder: (context, provider, child) {
        // Tanımladığımız screen'lardan BottomNavigationBar oluşturma
        final bottomNavigationBarItems = provider.screens
            .map(
              (screen) => BottomNavigationBarItem(
                icon: Icon(screen.icon),
                label: screen.title,
              ),
            )
            .toList();

        // Her ekran için [Navigator] örneğini başlatma
        final screens = provider.screens
            .map(
              (screen) => Navigator(
                key: screen.navigatorState,
                onGenerateRoute: screen.onGenerateRoute,
              ),
            )
            .toList();

        return WillPopScope(
          onWillPop: () async => provider.onWillPop(context),
          child: Scaffold(
            body: IndexedStack(
              children: screens,
              index: provider.currentTabIndex,
            ),
            bottomNavigationBar: BottomNavigationBar(
              type: BottomNavigationBarType.fixed,
              items: bottomNavigationBarItems,
              currentIndex: provider.currentTabIndex,
              onTap: provider.setTab,
            ),
          ),
        );
      },
    );
  }
}

Burada iki önemli olay var: WillPopScope ve IndexedStack.

IndexedStack, widget listesi alır (bizim için screens) ve tüm durumlarını saklar. Bizim durumumuzda, index değişikliğine göre, hangi widget’ın (ekranın) görünür olacağını ayarlar. Örnek olarak, birinci sayfanın belirli bir yerinde iken ikinci sayfaya geçiş yaptığımızı varsayalım. Tekrardan birinci sayfaya dönüş yaptığımızda birinci sayfanın kaldığımız yerinden devam ederiz.

WillPopScope, Android’in geri dönme butonunu yakalamaya yarar. Sistemin, geri düğmesini kullanıp kullanılmayacağını ve bu işi özelleştirmek istiyorsak kullanırız.

WillPopScope ile onWillPop olma durumunu tanımlamış olduk. Şimdi ise tekrardan NavigationProvider sınıfımıza dönerek onwillPop olduğu durumda neler yapacağımızı ele alalım.

navigator_provider.dart dosyasına şu metodu ekleyelim:

 Future<bool> onWillPop(BuildContext context) async {
    final currentNavigatorState = currentScreen.navigatorState.currentState;

    if (currentNavigatorState.canPop()) {
      currentNavigatorState.pop();
      return false;
    } else {
      if (currentTabIndex != FIRST_SCREEN) {
        setTab(FIRST_SCREEN);
        notifyListeners();
        return false;
      } else {
        return false;
        );
      }
    }
  }
//...

İlk başta canPop() ile bir geri dönüş rotasının olup olmadığını kontrol ediyoruz. Normal bir şekilde bulunduğumuz sayfaya push işlemi gerçekleştirdiysek bu metot geriye true değeri döndürecektir. Biz uygulamamızda, Root widget’a dönmek ve back butonuna basıldığında uygulamanın kapanmasını önlemek adına false döndürüyoruz.

İsimlendirilmiş Rotalar

Rotalar, ilgili rota içerisinde ele alınır. Ne zaman isimlendirilmiş rota kullanırsak Navigator en yakın durumu bulur ve bu aşamada onGenerateRoute(RouteSettings settings) metodunu çağırır.

Biz bu işlemi zaten yukarıda yaptık. Dahili (ana) Navigator için rota üreticisini yukarıdaki _screens değişkeni içerisinde bulabilirsiniz. Yine de aşağıdaki kod parçacığı ile durumu inceleyelim.

 .... 
 Route<dynamic> onGenerateRoute(RouteSettings settings) {
    print('Oluşturulan rota: ${settings.name}');
    switch (settings.name) {
      case PushedScreen.route:
        return MaterialPageRoute(builder: (_) => PushedScreen());
      case SimpleScreen.route:
        return MaterialPageRoute(builder: (_) => SimpleScreen());
      default:
        return MaterialPageRoute(builder: (_) => Root());
    }
  }
....

Bu alan bizim kök dizinden başlayarak isimlendirilmiş rotaları oluşturmamıza olanak sağlıyor.

Biz, oluşturduğumuz ekranların herhangi birinden başka bir sayfaya push işlemi gerçekleştirirken, Navigator.push() ile durumu ele alabiliriz. Ancak bu durum gidilen ekranda BottomNavigationBar‘ın görünüyor olmasına sebebiyet verecektir.

Eğer biz gidilen ekranda BottomNavigationBar‘ın görünüyor olmasını istemiyorsak, bunun için kök Navigator üzerinden push işlemi yapmamız gerekiyor. Yani, onGenerateRoute(RouteSettings settings) metodunda tanımlamamız gerekiyor. Bu işlemin ardından ilgili ekrana gitmek istediğimizde rootNavigator: true değeri atayarak BottomNavigationBar‘ın görünmesini engellemiş oluruz.

// BottomNavigationBar olmadan push işlemi
Navigator.of(context, rootNavigator: true).pushNamed(PushedScreen.route);

Main Widget (main.dart)

Şimdi ise main.dart dosyamızı düzenleyelim. Bildiğiniz gibi Flutter uygulamaları ilk buradan başlar. İşte tam da burada çağıracağımız MaterialApp() widget’ını, MultiProvider ile sarmalayarak tüm uygulamamızı NavigationProvider‘dan haberdar etmiş oluruz.

void main() => runApp(App());

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => NavigationProvider()),
      ],
      child: Builder(
        builder: (context) {
          return MaterialApp(
            onGenerateRoute: NavigationProvider.of(context).onGenerateRoute,
          );
        },
      ),
    );
  }
}

Sekmeler Arası Geçiş Yapma

Sekmelerin push işlemini zaten yukarıda ele almıştık. Peki ya sekmeler arasında geçiş yapmamız gerekirse? Bu esnada mevcut state’i korumak zor olacaktır. Örneğin, SECOND_SCREEN ekranından FIRST_SCREEN ekranına dönmeniz gerektiğini varsayalım. Bu durum için Navigator ile bir push gerçekleştirirseniz hata alırsınız. Peki ya ne yapmamız gerekiyor?

Yapmamız gereken basit bir şey var; İlgili ekran için NavigatonProvider içindeki setTab() metodunu çağırın. Yani tıpkı yukarıda yaptığımız gibi of() kullanarak sekmeler arası geçiş yapabiliriz.

NavigationProvider.of(context).setTab(FIRST_SCREEN);

Ekranın En Üstüne Dönme İşlemi

Bir çok mobil uygulamada karşılaştığınız bir durumu ele alacağız. BottomNavigationBar’da bulunan sekmelerden bir tanesinin ekranında aşağılara indiniz diyelim. Tekrardan ilgili sekmenin BottomNavigationBar’daki ikonuna tıklarsanız sizi sayfanın en üstüne götürür. Örnek olarak Instagram’a veya Twitter’a girip deneyimleyebilirsiniz.

Biz durumu ele alabilmek için zaten Screen modeli oluştururken screenController adında bir değişken tanımlamıştık.

Ayrıca bu işlemi test edebilmek adına SECOND_SCREEN içinde sınırsız elemanlı bir liste oluşturduk. Bu sayfaya giderek kaydırma işleminin çalışıp çalışmadığını test edebiliriz.

Şimdi ise tekrardan navigation_provider.dart dosyasına giderek ilgili metodu yazabiliriz.

void _scrollToTopOfPage() {
    if (currentScreen.scrollController != null &&
        currentScreen.scrollController.hasClients) {
      currentScreen.scrollController.animateTo(
        0.0,
        duration: const Duration(seconds: 1),
        curve: Curves.easeInOut,
      );
    }
  }

Yukarıda görüldüğü üzere ilk başta ele aldığımız ekranın bir screenController nesnesine sahip olmadığını kontrol ettik. Ayrıca aynı kontrol içerisinde ekranın pozisyonunun değişip değişmediğini kontrol ettik. hasClients() eğer ekranın pozisyonu değiştiyse true döndürür.

Şimdi tekrardan aynı sınıf içerisinde bulunan setTab() metoduna dönerek yukarıda tanımladığımız _scrollToTopOfPage() metodu çağıralım.

void setTab(int tab) {
    if (tab == currentTabIndex) {
      _scrollToTopOfPage(); // <= Eklenen yeni satır
    } else {
      _currentScreenIndex = tab;
      notifyListeners();
    }
  }

Artık ilgili ekranın en üstüne dönmek istediğimizde BottomNavigationBar elemanına tıklamamız yeterli.

Uygulamadan Çıkış Yapılıyor Uyarısı

Neredeyse tüm olasıkları ele aldık. Şimdi ise uygulamanın kök rotasında iken sistemin geri tuşuna basılırsa ekranda gösterilecek bir AlertDialog tanımlayalım.

exit_dialog.dart

class ExitAlertDialog extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: Text('Çkış'),
      content: Text("Çıkış yapmak istediğinizden emin misiniz?"),
      actions: <Widget>[
        FlatButton(
          onPressed: () {
            Navigator.of(context).pop(false);
          },
          child: Text(
            'İptal',
            style: Theme.of(context).textTheme.button.copyWith(
                  fontWeight: FontWeight.normal,
                ),
          ),
        ),
        FlatButton(
          onPressed: () {
            Navigator.of(context).pop(true);
          },
          child: Text('Çıkış'),
        ),
      ],
    );
  }
}

Yukarıdaki FlatButton’a tıklandığında pop() çağırılacak. Ancak bu sıradan bir pop() değil. Geriye bir değer döndürüyor. Eğer kullanıcı Çıkış‘a basarsa true, İptal‘e basarsa false değeri dönecek. Biz de buradan aldığımız değer ile işlemleri gerçekleştirmeye devam edeceğiz.

Bu işlemi de tamamladıktan sonra NavigationProvider’a dönerek onWillPop callback’ini tekrardan ele almamız gerekiyor.

Future<bool> onWillPop(BuildContext context) async {
    final currentNavigatorState = currentScreen.navigatorState.currentState;

    if (currentNavigatorState.canPop()) {
      currentNavigatorState.pop();
      return false;
    } else {
      if (currentTabIndex != FIRST_SCREEN) {
        setTab(FIRST_SCREEN);
        notifyListeners();
        return false;
      } else {
        return await showDialog(
          context: context,
          builder: (context) => ExitAlertDialog(),
        );
      }
    }
  }

Her şeyimiz hazır. Metotlarımızı çağırıp çağırmadığımızı kontrol edebilmemiz adına Root widget’ımıza dönelim. Orada WillPopScope içerisinde onWillPop durumunu ele alıp almadığımıza bakalım.

return WillPopScope(
          onWillPop: () async => provider.onWillPop(context),
          child: Scaffold(
          //...

Root widget içerisinde return ettiğimiz WillPopScope widget’ın, onWillPop durumuna, NavigationProvider sınıfında tanımladığımız onWillPop metodunu geçtik.

İşlem tamam. onWillPop durumu kontrol edip uygulamanın kök dizininde olduğunu belirlediğinde, burada gerçekleşen olası geri tuşuna basılmasında bir AlertDialog oluşacak ve AlertDialog’dan gelen bool verisi iie işlem yapacak.

Test Edelim

Uygulamamız istediğimiz gibi çalışıyor.

Kapanış

Artık uygulamalarımızın ekran state durumlarını kaybetmeden ekranlar arası gezinme yapabiliyoruz. Üstelik bunu yaparken dilersek kök dizinden oluşturulmuş isimlendirilmiş rotalar kullanarak BottomNavigationBar’dan kurtulabiliyoruz. Bu mimarinin üzerine uygulamamızı inşa etmeye devam ederek profesyonele yakın uygulamalar geliştirebiliriz.

Kaynak kodlarına buraya tıklayarak erişebilirsiniz.

İlk başta da belirttiğim gibi, bu uygulama daha önceden bilmediğiniz terimleri içerebilir. Bu yazıda, ilgili terimlerin nasıl çalıştığından ziyade başlıktaki probleme odaklanıldı. Lütfen bilmediğiniz terimler için resmi Flutter dökümantasyonunu inceleyin. Provider paketi ile ilgili bilgilere ulaşmak için buradaki bağlantıyı inceleyin.

Bir cevap yazın

E-posta hesabınız yayımlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir