Sparround

A feature from scratch: service → repository → notifier → view

This topic is practice: you combine everything from the branch into one feature. The order matters, because it surfaces wrong decisions early.

The right order — outside in:

1. Write down what the screen must show (on paper or as TODOs): which data, which states (loading, empty, error), which actions. 2. Write the domain model — shaped by the screen's needs, not by the backend's JSON. 3. Write the repository contract — derived from what the notifier needs. 4. Write the `…Local` implementation — canned data plus latency plus a failure rate. 5. Build the notifier and the screen — render all four states. 6. Write the tests — against the fake. 7. Only then write the DTO, the mapper and the …Remote implementation.

The reason for this order: at step 7 you should not have to touch the screen code. If you do, the contract had leaked the backend's shape.

The reverse order (starting from the backend) produces a familiar result: the DTO's shape flows into the domain model, the domain model into the screen, and the UI ends up a mirror of the JSON.

text
FEATURE: Loyallıq kartı (bal toplamaq və hədiyyəyə dəyişmək)

lib/
├── domain/
│   ├── models/
│   │   ├── loyalty_card.dart          ← points, tier, biznes getter-ləri
│   │   └── loyalty_reward.dart        ← id, title, cost
│   ├── repositories/
│   │   └── loyalty_repository.dart    ← abstract müqavilə (ADDIM 3)
│   └── failures/
│       └── failure.dart               ← + InsufficientPointsFailure
├── data/
│   ├── dto/
│   │   ├── loyalty_card_dto.dart      ← ADDIM 7 (sonda!)
│   │   └── loyalty_card_mapper.dart   ← ADDIM 7
│   ├── services/
│   │   └── loyalty_api_client.dart    ← ADDIM 7
│   └── repositories/
│       ├── loyalty_repository_local.dart   ← ADDIM 4 (əvvəl!)
│       └── loyalty_repository_remote.dart  ← ADDIM 7
└── ui/
    └── loyalty/
        ├── loyalty_notifier.dart      ← ADDIM 5 (+ .g.dart)
        ├── loyalty_state.dart         ← ADDIM 5 (+ .freezed.dart)
        ├── loyalty_page.dart          ← ADDIM 5
        └── widgets/
            ├── points_header.dart
            ├── reward_tile.dart
            └── empty_rewards.dart

test/
├── fixtures/
│   └── loyalty_fixtures.dart          ← aLoyaltyCard(), aReward()
├── fakes/
│   └── fake_loyalty_repository.dart   ← ADDIM 6
├── domain/models/loyalty_card_test.dart
├── data/dto/loyalty_card_mapper_test.dart
├── data/repositories/loyalty_repository_remote_test.dart
└── ui/loyalty/
    ├── loyalty_notifier_test.dart
    └── loyalty_page_test.dart

CƏMİ: 12 mənbə faylı + 7 test faylı.
Qeyd: `usecases/` qovluğu BOŞDUR — bu feature-də üç şərtdən heç biri
ödənmir (bir repository, sadə məntiq, bir ekran). Use-case yazılmır.

The complete file list for one feature — the practice's answer to "what do I create?"

dart
// ══ ADDIM 2: domain modeli (ekranın ehtiyacına görə) ══
// lib/domain/models/loyalty_card.dart
@freezed
abstract class LoyaltyCard with _$LoyaltyCard {
  const LoyaltyCard._();

  const factory LoyaltyCard({
    required int points,
    required LoyaltyTier tier,
    required int pointsToNextTier,
  }) = _LoyaltyCard;

  // Biznes qaydaları modeldə: ekran onları təkrarlamır.
  bool canAfford(LoyaltyReward reward) => points >= reward.cost;
  int missingPointsFor(LoyaltyReward reward) =>
      (reward.cost - points).clamp(0, reward.cost);
  double get tierProgress => pointsToNextTier == 0
      ? 1
      : points / (points + pointsToNextTier);
}

enum LoyaltyTier { bronze, silver, gold, unknown }


// ══ ADDIM 3: müqavilə (notifier-in ehtiyacından çıxarılır) ══
// lib/domain/repositories/loyalty_repository.dart
abstract interface class LoyaltyRepository {
  /// Cari istifadəçinin kartı.
  Future<Result<LoyaltyCard>> fetchCard();

  /// Mövcud hədiyyələr.
  Future<Result<List<LoyaltyReward>>> fetchRewards();

  /// Hədiyyəni bala dəyişir və yenilənmiş kartı qaytarır.
  /// Bal çatmadıqda `InsufficientPointsFailure`.
  Future<Result<LoyaltyCard>> redeem(String rewardId);
}

// Yoxlama: bu müqaviləyə baxıb backend-in REST, GraphQL, yoxsa
// lokal baza olduğunu təxmin etmək MÜMKÜN DEYİL. Müqavilə düzgündür.


// ══ ADDIM 4: Local implementasiya (backend-dən ƏVVƏL) ══
// lib/data/repositories/loyalty_repository_local.dart
class LoyaltyRepositoryLocal implements LoyaltyRepository {
  LoyaltyRepositoryLocal({
    this.latency = const Duration(milliseconds: 600),
    this.failureRate = 0,
  });

  final Duration latency;
  final double failureRate;

  LoyaltyCard _card = const LoyaltyCard(
    points: 1250,
    tier: LoyaltyTier.silver,
    pointsToNextTier: 750,
  );

  static const _rewards = [
    LoyaltyReward(id: 'r1', title: 'Pulsuz çatdırılma', cost: 500),
    LoyaltyReward(id: 'r2', title: '10% endirim', cost: 1000),
    LoyaltyReward(id: 'r3', title: 'Hədiyyə dəsti', cost: 5000),
  ];

  @override
  Future<Result<LoyaltyCard>> fetchCard() async {
    await Future<void>.delayed(latency);
    if (_fails()) return const Result.error(NetworkFailure());
    return Result.ok(_card);
  }

  @override
  Future<Result<List<LoyaltyReward>>> fetchRewards() async {
    await Future<void>.delayed(latency);
    if (_fails()) return const Result.error(NetworkFailure());
    return const Result.ok(_rewards);
  }

  @override
  Future<Result<LoyaltyCard>> redeem(String rewardId) async {
    await Future<void>.delayed(latency);
    final reward = _rewards.firstWhere((r) => r.id == rewardId);

    // Biznes qaydası burada da işləyir — ekran onu sınayır.
    if (!_card.canAfford(reward)) {
      return Result.error(
        InsufficientPointsFailure(missing: _card.missingPointsFor(reward)),
      );
    }
    _card = _card.copyWith(points: _card.points - reward.cost);
    return Result.ok(_card);
  }

  bool _fails() => failureRate > 0 && Random().nextDouble() < failureRate;
}

Steps 2-3: the domain model and the contract. Note the absence of JSON and HTTP.

dart
// ══ lib/ui/loyalty/loyalty_state.dart ══
@freezed
abstract class LoyaltyState with _$LoyaltyState {
  const factory LoyaltyState({
    required LoyaltyCard card,
    required List<LoyaltyReward> rewards,
    @Default(null) String? redeemingRewardId,   // hansı hədiyyə gedir
  }) = _LoyaltyState;
}

// ══ lib/ui/loyalty/loyalty_notifier.dart ══
@riverpod
class LoyaltyNotifier extends _$LoyaltyNotifier {
  @override
  Future<LoyaltyState> build() async {
    final repo = ref.watch(loyaltyRepositoryProvider);

    // İki sorğu paralel — ardıcıl gözləmək lazım deyil.
    final (cardResult, rewardsResult) =
        await (repo.fetchCard(), repo.fetchRewards()).wait;

    // İlk uğursuzluq bütün ekranı xəta halına aparır.
    return switch ((cardResult, rewardsResult)) {
      (Ok(value: final card), Ok(value: final rewards)) =>
        LoyaltyState(card: card, rewards: rewards),
      (Error(:final error), _) => throw error,
      (_, Error(:final error)) => throw error,
    };
    // `throw` → AsyncNotifier onu AsyncError-a çevirir.
  }

  /// Command: hədiyyəni bala dəyişir, nəticəni view-a qaytarır.
  Future<RedeemOutcome> redeem(String rewardId) async {
    final current = state.value;
    if (current == null) return RedeemOutcome.ignored;
    // İkiqat basılma müdafiəsi.
    if (current.redeemingRewardId != null) return RedeemOutcome.ignored;

    state = AsyncValue.data(
      current.copyWith(redeemingRewardId: rewardId),
    );

    final result =
        await ref.read(loyaltyRepositoryProvider).redeem(rewardId);

    switch (result) {
      case Ok(value: final card):
        state = AsyncValue.data(
          current.copyWith(card: card, redeemingRewardId: null),
        );
        return RedeemOutcome.success;
      case Error(error: InsufficientPointsFailure(:final missing)):
        state = AsyncValue.data(current.copyWith(redeemingRewardId: null));
        return RedeemOutcome.notEnoughPoints(missing: missing);
      case Error():
        state = AsyncValue.data(current.copyWith(redeemingRewardId: null));
        return RedeemOutcome.failed;
    }
  }
}

// ══ lib/ui/loyalty/loyalty_page.dart ══
class LoyaltyPage extends ConsumerWidget {
  const LoyaltyPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final l10n = AppLocalizations.of(context);
    final state = ref.watch(loyaltyNotifierProvider);

    return Scaffold(
      appBar: AppBar(title: Text(l10n.loyaltyTitle)),
      body: switch (state) {
        // 1) Boş hal (xəta deyil)
        AsyncData(:final value) when value.rewards.isEmpty =>
          EmptyRewards(points: value.card.points),

        // 2) Məlumat
        AsyncData(:final value) => Column(children: [
            PointsHeader(card: value.card),
            Expanded(
              child: ListView.builder(
                itemCount: value.rewards.length,
                itemBuilder: (context, i) {
                  final reward = value.rewards[i];
                  return RewardTile(
                    reward: reward,
                    // Biznes qaydası MODELDƏN gəlir, widget-də deyil.
                    canAfford: value.card.canAfford(reward),
                    isRedeeming: value.redeemingRewardId == reward.id,
                    onRedeem: () => _redeem(context, ref, reward),
                  );
                },
              ),
            ),
          ]),

        // 3) Xəta
        AsyncError(:final error) => ErrorView(
            message: error is Failure
                ? presentFailure(error, l10n).message
                : l10n.errorGeneric,
            onRetry: () => ref.invalidate(loyaltyNotifierProvider),
          ),

        // 4) Yüklənir
        _ => const Center(child: CircularProgressIndicator()),
      },
    );
  }

  Future<void> _redeem(
      BuildContext context, WidgetRef ref, LoyaltyReward reward) async {
    final outcome =
        await ref.read(loyaltyNotifierProvider.notifier).redeem(reward.id);
    if (!context.mounted) return;

    final l10n = AppLocalizations.of(context);
    final message = switch (outcome) {
      RedeemSuccess() => l10n.redeemSuccess(reward.title),
      RedeemNotEnoughPoints(:final missing) =>
        l10n.redeemNeedMorePoints(missing),
      RedeemFailed() => l10n.errorGeneric,
      RedeemIgnored() => null,
    };
    if (message == null) return;

    ScaffoldMessenger.of(context)
        .showSnackBar(SnackBar(content: Text(message)));
  }
}

Step 5: the notifier and the screen. With the `…Local` implementation all four states are genuinely exercised.

Practice (this stage's main assignment). Repeat the seven steps above for one feature in your own project. Pick the feature yourself — keep it small (one screen, two or three operations) but real.

Stay faithful to the order: the `…Local` implementation before the backend. That is the entire point of the sequence.

Done means:

1. flutter run -t lib/main_development.dart runs the screen and you have seen all four states with your own eyes (with latency and failureRate: 0.3). 2. Five test files are green (model, mapper, repository, notifier, widget). 3. After adding the …Remote implementation, the screen code and the notifier are unchanged — only one provider line. 4. grep -rEn "package:(flutter|http)" lib/domain finds nothing.

The third item is this branch's key check: if it holds, your layering works.

📚 Sources and documentation