Day 29: State Management with Provider

Connect Pixly's repository to the UI with Provider and ChangeNotifier. Model loading, error and data states, choose between watch, read and select, and wire everything up with MultiProvider.

Day 28 မှာ Pexels API ကို ခေါ်ပေးမယ့် PhotoRepository ကို ရေးခဲ့ပြီးပါပြီ။ ဒါပေမယ့် screen ပေါ်မှာ ဘာမှ မပေါ်သေးပါဘူး။ ဒီနေ့မှာတော့ repository က ယူလာတဲ့ data ကို UI ဆီ ပို့ပေးမယ့် state management အပိုင်းကို ဆက်လုပ်ကြပါမယ်။ ဒီနေ့ ပြီးသွားရင် app ဖွင့်လိုက်တာနဲ့ Pexels က ဓာတ်ပုံ ၂၀ ရဲ့ အချက်အလက်တွေကို list နဲ့ ပြပေးနိုင်ပြီ ဖြစ်ပါတယ်။

ဘာလို့ State Management လိုတာလဲ?

Day 17 မှာ StatefulWidget နဲ့ setState ကို လေ့လာခဲ့ပါတယ်။ Widget တခုတည်းအတွက်ပဲ လိုတဲ့ state (ဥပမာ - checkbox အမှန်ခြစ်ထား/မထား၊ TextField ထဲက စာ) အတွက်ဆို setState က လုံလောက်ပါတယ်။

ဒါပေမယ့် Pixly မှာ ဓာတ်ပုံ list က

  • Home screen မှာ ပြရမယ်
  • Detail screen ကို ပို့ပေးရမယ် (Day 32)
  • Search screen, Bookmarks screen တွေကလဲ ဆင်တူ data ကို သုံးမယ် (Day 34, 35)

ဆိုတော့ widget တခုထဲမှာပဲ သိမ်းထားလို့ မရတော့ပါဘူး။ Widget tree ရဲ့ အပေါ်ပိုင်းမှာ တနေရာထဲ ထားပြီး လိုအပ်တဲ့ widget တွေက ယူသုံး၊ data ပြောင်းရင် အဲ့ဒီ widget တွေကိုပဲ rebuild လုပ်ပေးတဲ့ နည်းလမ်း လိုလာပါတယ်။ ဒါကို state management လို့ ခေါ်ပါတယ်။

Flutter မှာ state management နည်းလမ်း အများကြီး ရှိပါတယ်။ Pixly မှာတော့ Flutter ရဲ့ built-in ChangeNotifier နဲ့ provider package ကို သုံးပါမယ်။ နားလည်ရ လွယ်ပြီး ဒီနောက်ပိုင်း သုံးမယ့် Riverpod (Day 41) လို package တွေရဲ့ အခြေခံ သဘောတရားတွေကို နားလည်ဖို့ အကောင်းဆုံး အစပဲ ဖြစ်ပါတယ်။

fvm flutter pub add provider

ChangeNotifier

ChangeNotifier က Flutter မှာ ပါပြီးသား class တခု ဖြစ်ပါတယ်။ သူ့ကို extend လုပ်ထားတဲ့ class က data ပြောင်းတိုင်း notifyListeners() ကို ခေါ်ပြီး "ငါ့ data ပြောင်းသွားပြီ" လို့ နားထောင်နေသူ (listener) တွေကို အသိပေးပါတယ်။

// lib/providers/photos_provider.dart
import 'package:flutter/foundation.dart';
import 'package:pixly/core/result.dart';
import 'package:pixly/models/photo.dart';
import 'package:pixly/repositories/photo_repository.dart';

/// Holds the curated photos and tells listeners when they change.
class PhotosProvider extends ChangeNotifier {
  PhotosProvider(this._repository);

  final PhotoRepository _repository;

  List<Photo> _photos = [];
  bool _isLoading = false;
  String? _error;
  bool _disposed = false;

  List<Photo> get photos => List.unmodifiable(_photos);
  bool get isLoading => _isLoading;
  String? get error => _error;

  Future<void> loadCurated() async {
    if (_isLoading) return; // already loading, ignore extra taps
    _isLoading = true;
    _error = null;
    notifyListeners();

    final result = await _repository.curated();
    if (_disposed) return; // the screen went away while we were waiting

    switch (result) {
      case Success(:final data):
        _photos = data.photos;
      case Failure(:final message):
        _error = message;
    }

    _isLoading = false;
    notifyListeners();
  }

  @override
  void dispose() {
    _disposed = true;
    super.dispose();
  }
}

အဖြစ်များတဲ့ State တွေ

Network ကနေ data ယူတဲ့ screen အများစုမှာ အောက်က state တွေကို ကိုင်တွယ်ဖို့ လိုပါတယ်။

State စစ်တဲ့ပုံ UI မှာ ပြမယ့်ပုံ
Loading isLoading == true Progress indicator
Error error != null Error message + Try again button
Empty အောင်မြင်ပေမယ့် photos.isEmpty "No photos yet" message
Data photos ထဲမှာ ပုံ ရှိ ဓာတ်ပုံ list

Request အောင်မြင်သွားပေမယ့် ပုံ တပုံမှ ပြန်မလာတာ (Empty) ကို မေ့တတ်ကြပါတယ်။ ဒီ case ကို မကိုင်တွယ်ရင် user က screen အလွတ်ကြီးပဲ မြင်ရမှာ ဖြစ်ပါတယ်။

Private fields + Public getters

_photos, _isLoading, _error တွေကို _ နဲ့ private ထားပြီး getter တွေကနေပဲ ဖတ်ခွင့်ပေးထားပါတယ်။ ဒါမှ UI ကနေ provider._isLoading = false လို state ကို တိုက်ရိုက် ပြင်လို့ မရတော့ဘဲ state ပြောင်းတာ အကုန်လုံး loadCurated() လို method တွေကနေပဲ ဖြစ်မှာပါ။ photos getter ကလဲ List.unmodifiable နဲ့ ပြန်ပေးတဲ့အတွက် အပြင်ကနေ photos.clear() လို့ ခေါ်ရင် error တက်ပါလိမ့်မယ်။ State ကို ဘယ်နေရာကနေ ပြောင်းလဲဆိုတာ ရှင်းရှင်းလင်းလင်း ဖြစ်နေတာက bug ရှာတဲ့အခါ အရမ်း အရေးကြီးပါတယ်။

notifyListeners() ကို ၂ ခါ ခေါ်ထားတာ

  1. Loading စတဲ့အခါ - UI က progress indicator ပြနိုင်ဖို့
  2. ပြီးသွားတဲ့အခါ - data ဒါမှမဟုတ် error ကို ပြနိုင်ဖို့

notifyListeners() မခေါ်ရင် data ပြောင်းသွားပေမယ့် UI က မသိတဲ့အတွက် screen ပေါ်မှာ ဘာမှ ပြောင်းမှာ မဟုတ်ပါဘူး။ ChangeNotifier သုံးတဲ့အခါ အမှားအများဆုံးက notifyListeners() ခေါ်ဖို့ မေ့တာပါ။

Loading guard

if (_isLoading) return; က user က "Try again" ကို ဆက်တိုက် နှိပ်လိုက်ရင် request တွေ အများကြီး တပြိုင်နက် ထွက်မသွားအောင် ကာကွယ်ပေးပါတယ်။ Pexels မှာ rate limit ရှိတဲ့အတွက် request အပိုတွေက တကယ် ကုန်ကျပါတယ်။ Day 31 မှာ infinite scroll လုပ်တဲ့အခါ ဒီ guard က ပိုတောင် အရေးကြီးလာပါလိမ့်မယ်။

Dispose ပြီးမှ ပြန်လာတဲ့ response

await _repository.curated() က network ကို စောင့်နေတုန်း provider ကို ပိုင်ဆိုင်တဲ့ ChangeNotifierProvider ကို widget tree ကနေ ဖယ်လိုက်ရင် provider က dispose() ဖြစ်သွားပါပြီ။ (Pixly မှာတော့ provider ကို MaterialApp ရဲ့ အပေါ်မှာ ထားထားတဲ့အတွက် screen ပိတ်ရုံနဲ့ dispose မဖြစ်ပါဘူး။ Screen တခုအတွက်ပဲ provider ထားတဲ့ app တွေမှာတော့ screen ပိတ်တာနဲ့ dispose ဖြစ်ပါတယ်။) ပြီးမှ response ပြန်ရောက်လာပြီး notifyListeners() ကို ခေါ်ရင် debug mode မှာ "used after being disposed" ဆိုတဲ့ assertion error တက်ပါလိမ့်မယ်။

ဒါကြောင့် _disposed flag လေး ထားပြီး dispose() ကို override လုပ်ထားပါတယ်။ await ပြီးတဲ့နောက် provider dispose ဖြစ်သွားပြီဆိုရင် state ကို မပြင်တော့ဘဲ ချက်ချင်း return လုပ်လိုက်ပါတယ်။ await စောင့်နေတုန်း သက်တမ်း ကုန်သွားနိုင်တဲ့ object (disposed provider, unmounted widget စတာတွေ) ကို await ပြီးမှ ဆက်သုံးမယ်ဆိုရင် ဒီလို စစ်ဖို့ လိုတယ်ဆိုတာ မှတ်ထားသင့်ပါတယ်။

Result ကို switch နဲ့ ကိုင်တွယ်ခြင်း

switch (result) ထဲမှာ Success(:final data) လို့ ရေးထားတာက Day 9 မှာ ပြောခဲ့တဲ့ object pattern ပါ။ result က Success ဖြစ်ခဲ့ရင် သူ့ရဲ့ data field ကို data ဆိုတဲ့ variable ထဲ ထုတ်ပေးပါတယ်။ Result က sealed class ဖြစ်တဲ့အတွက် Failure case ကို မေ့ပြီး မရေးမိရင် compiler က error ပြပေးပါတယ်။

Provider ကို App ထဲ ထည့်ခြင်း

PhotosProvider ကို widget တွေက ယူသုံးနိုင်အောင် widget tree ရဲ့ အပေါ်ဆုံးမှာ ထားပေးရပါမယ်။

// lib/main.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:pixly/config/constants.dart';
import 'package:pixly/config/theme.dart';
import 'package:pixly/core/api/api_client.dart';
import 'package:pixly/providers/photos_provider.dart';
import 'package:pixly/repositories/photo_repository.dart';
import 'package:pixly/screens/home_screen.dart';
import 'package:provider/provider.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: '.env');

  // Show the Poppins font license on Flutter's licenses page.
  LicenseRegistry.addLicense(() async* {
    final license = await rootBundle.loadString('assets/fonts/OFL.txt');
    yield LicenseEntryWithLineBreaks(['Poppins'], license);
  });

  runApp(
    MultiProvider(
      providers: [
        Provider(create: (_) => PhotoRepository(ApiClient())),
        ChangeNotifierProvider(
          create: (context) =>
              PhotosProvider(context.read<PhotoRepository>())..loadCurated(),
        ),
      ],
      child: const PixlyApp(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: AppConstants.appName,
      debugShowCheckedModeBanner: false,
      theme: AppTheme.lightTheme,
      darkTheme: AppTheme.darkTheme,
      themeMode: ThemeMode.system,
      home: const HomeScreen(),
    );
  }
}
  • MultiProvider - provider တွေ အများကြီးကို တပြိုင်နက် ထည့်ချင်တဲ့အခါ အထပ်ထပ် nest မလုပ်ရအောင် list အနေနဲ့ ထည့်ခွင့်ပေးပါတယ်။
  • Provider - ပြောင်းလဲမှု မရှိတဲ့ object (ဒီမှာ PhotoRepository) ကို ပေးဖို့ပါ။ PhotoRepository ကို app တခုလုံးမှာ တခုတည်း ဆောက်ပြီး မျှသုံးသွားမှာ ဖြစ်ပါတယ်။
  • ChangeNotifierProvider - ChangeNotifier ကို ပေးဖို့ပါ။ notifyListeners() ခေါ်တိုင်း သူ့ကို နားထောင်နေတဲ့ widget တွေကို rebuild လုပ်ပေးပြီး ဒီ provider ကို widget tree ကနေ ဖယ်လိုက်တဲ့အခါ dispose() ကိုလဲ အလိုအလျောက် ခေါ်ပေးပါတယ်။
  • context.read<PhotoRepository>() - provider ၂ ခုကြားမှာ PhotosProvider က PhotoRepository ကို လိုတဲ့အတွက် အပေါ်က provider ကို read နဲ့ ယူထားပါတယ်။ ဒီလိုလုပ်ဖို့ PhotoRepository provider ကို list ထဲမှာ အရင် ထည့်ထားရပါမယ်။
  • ..loadCurated() - Dart ရဲ့ cascade operator (..) ပါ။ object ကို ဆောက်ပြီး method ကို ခေါ်ပြီးမှ object ကိုယ်တိုင်ကို ပြန်ပေးပါတယ်။ ဒီနေရာမှာ loadCurated() က Future ကို ပြန်ပေးပေမယ့် cascade က အဲ့ဒီ Future ကို await မလုပ်ပါဘူး။ ဒါကြောင့် provider ကို ချက်ချင်း ပြန်ပေးပြီး loading က နောက်ကွယ်မှာ ဆက်လုပ်နေမှာပါ။ UI က အရင်ဆုံး loading state ကို မြင်ရပြီး data ရောက်တဲ့အခါ notifyListeners() ကနေ rebuild ဖြစ်သွားပါတယ်။
  • Lazy creation - Provider နဲ့ ChangeNotifierProvider ရဲ့ create က default အနေနဲ့ lazy ဖြစ်ပါတယ်။ App စဖွင့်တာနဲ့ ဆောက်တာ မဟုတ်ဘဲ widget တခုခုက ပထမဆုံး အကြိမ် watch/read လုပ်မှ ဆောက်ပါတယ်။ Pixly မှာတော့ HomeScreen က ချက်ချင်း watch လုပ်တဲ့အတွက် app ပွင့်ပြီး ချက်ချင်းနီးပါး loading စသွားမှာ ဖြစ်ပါတယ်။

watch, read, select

Widget တွေကနေ provider ကို ယူသုံးဖို့ BuildContext extension method ၃ ခု ရှိပါတယ်။

Method ဘယ်အချိန် သုံးမလဲ Rebuild ဖြစ်လား
context.watch<T>() build ထဲမှာ data ကို ပြဖို့ notifyListeners() ခေါ်တိုင်း rebuild
context.read<T>() Button နှိပ်တာလို event ထဲမှာ method ခေါ်ဖို့ Rebuild မဖြစ်
context.select<T, R>(...) Data ရဲ့ အစိတ်အပိုင်းတခုပဲ လိုတဲ့အခါ ရွေးထားတဲ့ value ပြောင်းမှပဲ rebuild
// lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:pixly/config/assets.dart';
import 'package:pixly/config/constants.dart';
import 'package:pixly/providers/photos_provider.dart';
import 'package:pixly/utils/color_utils.dart';
import 'package:pixly/widgets/empty_state.dart';
import 'package:provider/provider.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Row(
          spacing: 12,
          children: [
            SvgPicture.asset(AppAssets.logo, width: 32, height: 32),
            const Text(AppConstants.appName),
          ],
        ),
      ),
      body: const _PhotosBody(),
    );
  }
}

class _PhotosBody extends StatelessWidget {
  const _PhotosBody();

  @override
  Widget build(BuildContext context) {
    final provider = context.watch<PhotosProvider>();
    final photos = provider.photos;

    if (provider.isLoading && photos.isEmpty) {
      return const Center(child: CircularProgressIndicator());
    }

    if (provider.error != null && photos.isEmpty) {
      return EmptyState(
        title: 'Something went wrong',
        message: provider.error!,
        action: FilledButton.icon(
          onPressed: () => context.read<PhotosProvider>().loadCurated(),
          icon: const Icon(Icons.refresh),
          label: const Text('Try again'),
        ),
      );
    }

    if (photos.isEmpty) {
      return const EmptyState(
        title: 'No photos yet',
        message: 'Beautiful photos from Pexels will show up here soon.',
      );
    }

    return ListView.builder(
      itemCount: photos.length,
      itemBuilder: (context, index) {
        final photo = photos[index];
        return ListTile(
          leading: CircleAvatar(backgroundColor: colorFromHex(photo.avgColor)),
          title: Text(
            photo.alt.isEmpty ? 'Untitled' : photo.alt,
            maxLines: 1,
            overflow: .ellipsis,
          ),
          subtitle: Text('by ${photo.photographer}'),
        );
      },
    );
  }
}
  • build ထဲမှာ context.watch<PhotosProvider>() ကို သုံးထားတဲ့အတွက် PhotosProvider က notifyListeners() ခေါ်တိုင်း ဒီ widget က အသစ် build ဖြစ်ပြီး loading → list ပြောင်းသွားမှာပါ။
  • "Try again" button ရဲ့ onPressed ထဲမှာတော့ context.read ကို သုံးထားပါတယ်။ Button နှိပ်တဲ့အချိန်မှာ method ခေါ်ရုံပဲ လိုပြီး rebuild နဲ့ မဆိုင်လို့ပါ။ onPressed လို callback ထဲမှာ watch ကို သုံးရင် provider package က debug mode မှာ assertion error ပြပါလိမ့်မယ်။
  • HomeScreen တခုလုံးမှာ watch မလုပ်ဘဲ body အပိုင်းကိုပဲ _PhotosBody ဆိုပြီး widget သီးသန့် ခွဲထားပါတယ်။ ဒါမှ data ပြောင်းတဲ့အခါ AppBar က rebuild မဖြစ်တော့ဘဲ body ပဲ rebuild ဖြစ်မှာ ဖြစ်ပါတယ်။

select ကို ဥပမာ ပြရရင် AppBar မှာ ပုံ အရေအတွက်ပဲ ပြချင်တယ်ဆိုပါစို့။ watch လိုပဲ select ကိုလဲ widget ရဲ့ build method ထဲမှာ ခေါ်ရပါတယ်။

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

  @override
  Widget build(BuildContext context) {
    final count = context.select<PhotosProvider, int>((p) => p.photos.length);
    return Text('$count photos');
  }
}

ဒီလိုဆိုရင် PhotosProvider ရဲ့ loading state ပြောင်းတာ၊ error ပြောင်းတာတွေကြောင့် rebuild မဖြစ်ဘဲ ပုံ အရေအတွက် ပြောင်းမှပဲ rebuild ဖြစ်မှာပါ။ (Theme ပြောင်းတာ၊ parent rebuild ဖြစ်တာ စတဲ့ provider နဲ့ မဆိုင်တဲ့ အကြောင်းတွေကြောင့်တော့ rebuild ဖြစ်နိုင်ပါသေးတယ်။)

avgColor ကိုတော့ CircleAvatar ရဲ့ background အဖြစ် ပြဖို့ hex string ကို Color ပြောင်းပေးတဲ့ function လေး တခု ရေးထားပါတယ်။

// lib/utils/color_utils.dart
import 'package:flutter/painting.dart';

/// Turns a hex string like "#7A8C6E" into a [Color]. Falls back to grey.
Color colorFromHex(String hex) {
  final value = int.tryParse(hex.replaceFirst('#', ''), radix: 16);
  if (value == null || hex.replaceFirst('#', '').length != 6) {
    return const Color(0xFF9E9E9E);
  }
  return Color(0xFF000000 | value);
}

#7A8C6E လို hex မှာ alpha (opacity) မပါတဲ့အတွက် 0xFF000000 နဲ့ OR (|) လုပ်ပြီး အပြည့် မြင်ရအောင် alpha ကို FF ထည့်ပေးတာ ဖြစ်ပါတယ်။

ရလဒ်

Loading state
Loading state
Photos loaded
Photos loaded
Error state with a retry button
Error state with a retry button

(Screenshot တွေမှာ ပြထားတဲ့ ဓာတ်ပုံ အချက်အလက်တွေက Pexels response ပုံစံအတိုင်း ပြင်ဆင်ထားတဲ့ sample data ဖြစ်ပါတယ်။ ကိုယ့် API key နဲ့ run ရင်တော့ Pexels က curated photo အစစ်တွေကို မြင်ရမှာပါ။)

ပုံတွေ ကိုယ်တိုင်ကိုတော့ မပြရသေးပါဘူး။ မနက်ဖြန် Day 30 မှာ ဒီ list ကို Day 24 က GridView နဲ့ ပြောင်းပြီး ဓာတ်ပုံတွေကို cache လုပ်ပြီး ပြပေးမှာ ဖြစ်ပြီး loading state ကိုလဲ shimmer effect နဲ့ ပိုလှအောင် လုပ်သွားမှာ ဖြစ်ပါတယ်။

Source code: ဒီနေ့ရဲ့ code အပြည့်အစုံကို GitHub မှာ run ကြည့်လို့ ရပါတယ်။


ဒီနေ့ Day 29 အတွက်ကတော့ ဒီလောက်ပဲ ဖြစ်ပါတယ်။ အဆုံးထိ ဖတ်ပေးတဲ့အတွက် အများကြီး ကျေးဇူးတင်ပါတယ်။ နားမလည်တာတွေ အဆင်မပြေတာတွေ ရှိခဲ့ရင်လဲ အောက်မှာပေးထားတဲ့ discord server ထဲမှာ လာရောက်ဆွေးနွေးနိုင်ပါတယ်။ နောက်နေ့တွေမှာလဲ ဆက်လက်ပြီး sharing လုပ်သွားပေးသွားမှာ ဖြစ်တဲ့အတွက် subscribe လုပ်ထားဖို့ ဖိတ်ခေါ်ပါတယ်။