Sparround

Command pattern və yan effektlər

Rəsmi bələdçidə command — view model-in view-a açdığı hərəkətdir. Ayrı dizayn pattern səhifəsi ona həsr olunub və üç konkret problemi həll edir:

1. İkiqat basılma. İstifadəçi düyməni sürətlə iki dəfə basır və əməliyyat iki dəfə icra olunur. Command execute() metodunda if (_running) yoxlaması ilə erkən qayıdır. 2. State-in çoxalması. Command olmadan hər hərəkət üçün ayrı sahələr lazımdır: runningLoad, errorLoad, runningEdit, errorEdit. Command bu vəziyyəti öz içində saxlayır. 3. UI hərəkətlərinin tetiklənməsi. Xəta dialoqu, snackbar, naviqasiya — command-ın state-i (running, error, completed) bunları asanlaşdırır.

Rəsmi implementasiya Command0 (arqumentsiz) və Command1<T, A> (bir arqumentli) siniflərini verir; onlar ChangeNotifier-dən miras alır və running, error, completed, result ilə clearResult() açır.

Riverpod-da ayrı Command sinfi lazım deyil: notifier-in metodu command rolunu oynayır, AsyncValue isə running/error vəziyyətlərini daşıyır. Lakin problemlər qalır — ikiqat basılma və yan effektlərin təkrarı Riverpod-da da baş verir, sadəcə həlli fərqlidir.

dart
// Rəsmi dizayn pattern səhifəsindəki forma (sadələşdirilmiş).
typedef CommandAction0<T> = Future<Result<T>> Function();
typedef CommandAction1<T, A> = Future<Result<T>> Function(A);

abstract class Command<T> extends ChangeNotifier {
  bool _running = false;
  Result<T>? _result;

  bool get running => _running;
  bool get error => _result is Error;
  bool get completed => _result is Ok;
  Result<T>? get result => _result;

  /// Nəticə bir dəfə istifadə olunduqdan sonra təmizlənir —
  /// beləliklə snackbar/dialoq TƏKRARLANMIR.
  void clearResult() {
    _result = null;
    notifyListeners();
  }

  Future<void> _execute(CommandAction0<T> action) async {
    // İkiqat basılmanın qarşısı: əməliyyat gedirsə, erkən qayıdır.
    if (_running) return;

    _running = true;
    _result = null;
    notifyListeners();

    try {
      _result = await action();
    } finally {
      _running = false;
      notifyListeners();
    }
  }
}

final class Command0<T> extends Command<T> {
  Command0(this._action);
  final CommandAction0<T> _action;

  Future<void> execute() async => _execute(_action);
}

final class Command1<T, A> extends Command<T> {
  Command1(this._action);
  final CommandAction1<T, A> _action;

  Future<void> execute(A argument) async =>
      _execute(() => _action(argument));
}

// ── View model-də istifadə ──
class OrderViewModel extends ChangeNotifier {
  OrderViewModel({required OrderRepository repository})
      : _repository = repository {
    load = Command0(_load);
    cancel = Command1(_cancel);
  }

  final OrderRepository _repository;

  late final Command0<List<Order>> load;
  late final Command1<Order, String> cancel;

  Future<Result<List<Order>>> _load() => _repository.fetchMine();
  Future<Result<Order>> _cancel(String id) => _repository.cancel(id);
}

// ── View-da: ListenableBuilder command-a abunə olur ──
// ListenableBuilder(
//   listenable: viewModel.load,
//   builder: (context, child) {
//     if (viewModel.load.running) return const CircularProgressIndicator();
//     if (viewModel.load.error) return const ErrorView();
//     return child!;
//   },
//   child: ...,
// )

Rəsmi `Command` sinfinin forması — `ChangeNotifier` əsaslı layihələrdə birbaşa işlədilir.

Riverpod-da eyni problemlərin həlli. Ayrı Command sinfi yazmaq lazım deyil, lakin üç məsələni yenə həll etmək lazımdır.

1. İkiqat basılma. İki üsul:

  • State-də isSubmitting bayrağı (ya da sealed union-da submitting variantı) və metodun başında yoxlama.
  • View tərəfində düyməni deaktiv etmək: onPressed: state.isSubmitting ? null : () => .... Bu, hər iki tərəfdə edilməlidir — düymə deaktiv olsa da, klaviatura ya da başqa yol qalır.

2. Yan effektlərin təkrarlanması. Naviqasiya, snackbar, dialoq, analitika hadisəsi bir dəfə baş verməlidir. Problem build-in təbiətindən doğur: o, istənilən anda təkrar çağırıla bilər. Riverpod-da doğru yer:

  • ref.listen(provider, (prev, next) { ... })build içində təhlükəsizdir, yalnız dəyişiklikdə işləyir.
  • Ya da: notifier-in metodu nəticə qaytarır, view isə await-dan sonra effekti icra edir. Bu variant daha aydındır, çünki səbəb-nəticə bir yerdə görünür.

3. `context.mounted` yoxlaması. Async əməliyyatdan sonra widget ağacdan silinmiş ola bilər. await-dan sonra context işlədən hər yerdə if (!context.mounted) return; lazımdır — bu, ən çox buraxılan yoxlamadır və məhsul rejimində "Looking up a deactivated widget's ancestor" xətası kimi görünür.

dart
// ══ Notifier: command + ikiqat basılma müdafiəsi ══
@riverpod
class CheckoutNotifier extends _$CheckoutNotifier {
  @override
  CheckoutState build() =>
      CheckoutState.editing(form: CheckoutForm.empty());

  Future<SubmitOutcome> submit() async {
    // 1) Müdafiə: yalnız editing/failed vəziyyətindən başlayır.
    final form = switch (state) {
      CheckoutEditing(:final form) => form,
      CheckoutFailed(:final form) => form,
      CheckoutSubmitting() => null,     // artıq gedir → təkrar yox
      CheckoutSubmitted() => null,      // artıq bitib
    };
    if (form == null) return SubmitOutcome.ignored;

    state = CheckoutState.submitting(form: form);

    final result = await ref.read(orderRepositoryProvider).place(form);

    switch (result) {
      case Ok(:final value):
        state = CheckoutState.submitted(order: value);
        // 2) Nəticə qaytarılır — naviqasiya BURADA edilmir.
        return SubmitOutcome.success(orderId: value.id);
      case Error(error: ValidationFailure(:final fieldErrors)):
        state = CheckoutState.editing(form: form, fieldErrors: fieldErrors);
        return SubmitOutcome.invalid;
      case Error(:final error):
        state = CheckoutState.failed(form: form, failure: error as Failure);
        return SubmitOutcome.failed;
    }
  }
}

// ══ View: yan effekt bir dəfə, mounted yoxlaması ilə ══
class CheckoutPage extends ConsumerWidget {
  const CheckoutPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(checkoutNotifierProvider);
    final isSubmitting = state is CheckoutSubmitting;

    return Scaffold(
      body: CheckoutFormView(state: state),
      bottomNavigationBar: FilledButton(
        // 3) Düymə də deaktiv edilir — iki səviyyəli müdafiə.
        onPressed: isSubmitting ? null : () => _submit(context, ref),
        child: isSubmitting
            ? const CircularProgressIndicator()
            : Text(AppLocalizations.of(context).submitOrder),
      ),
    );
  }

  Future<void> _submit(BuildContext context, WidgetRef ref) async {
    final outcome =
        await ref.read(checkoutNotifierProvider.notifier).submit();

    // 4) `await`-dan SONRA context işlədilir → mounted yoxlanılır.
    if (!context.mounted) return;

    final l10n = AppLocalizations.of(context);
    switch (outcome) {
      case SubmitSuccess(:final orderId):
        // Naviqasiya view-da: bir dəfə, açıq şəkildə.
        context.go('/orders/$orderId?justCreated=true');
      case SubmitOutcome.invalid:
        // Sahə xətaları artıq state-dədir; əlavə snackbar lazım deyil.
        break;
      case SubmitOutcome.failed:
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(l10n.submitFailed)),
        );
      case SubmitOutcome.ignored:
        break;
    }
  }
}

// ══ Alternativ: ref.listen ilə (state dəyişikliyinə reaksiya) ══
// build içində:
// ref.listen(checkoutNotifierProvider, (previous, next) {
//   if (next case CheckoutSubmitted(:final order)) {
//     context.go('/orders/${order.id}');
//   }
// });
// Bu variant "state-ə görə naviqasiya" məntiqi üçün uyğundur
// (deep link, avtomatik yönləndirmə); düymə basılması üçün isə
// yuxarıdaki `await` variantı daha aydındır.

Riverpod-da command: ikiqat basılmanın qarşısı, nəticənin qaytarılması və yan effektin bir dəfə icrası.

Yan effekti `build`-də icra etməmək. Ən təhlükəli səhv budur:

if (state.isSubmitted) { context.go('/success'); }build-in gövdəsində.

build istənilən anda təkrar çağırıla bilər (ata widget rebuild oldu, klaviatura açıldı, ekran döndü). Nəticə: iki naviqasiya, üst-üstə düşən dialoqlar, təkrarlanan analitika hadisələri. Bəzən daha pisi — build zamanı naviqasiya Flutter-in assert-ini pozur.

Doğru yerlər: ref.listen (Riverpod), callback-in içi (onPressedawait → effekt), ya da addPostFrameCallback (nadir hallarda).

Praktika. Bir yazma əməliyyatını (submit ya da delete) tam qur: notifier-də ikiqat basılma müdafiəsi, nəticə enum-u, view-da context.mounted yoxlaması və uğur/xəta üçün fərqli davranış.

Sonra sına: düyməni sürətlə üç dəfə bas. Hazır sayılır: şəbəkəyə bir sorğu gedir (loqda görünür), bir snackbar görünür və naviqasiya bir dəfə baş verir.

📚 Mənbələr və sənədlər

  • Command pattern-irəsmidocs.flutter.dev

    Bu mövzunun mənbəyi: `Command0`/`Command1`, `running`/`error`/`completed`, `clearResult()` və ikiqat basılma müdafiəsi.

  • Riverpod: ref qaydalarırəsmiriverpod.dev

    `ref.listen` — yan effekt üçün doğru yer; `ref.read` isə command-ların içində.

  • Optimistic state pattern-irəsmidocs.flutter.dev

    Yazma nəticəsini gözləmədən UI-ı yeniləmək və uğursuzluqda geri qaytarmaq.

  • Arxitektura tövsiyələri: commandrəsmidocs.flutter.dev

    Command-ların niyə tövsiyə olunduğu: render xətalarının qarşısı və hadisələrin standartlaşdırılması.