Skip to content

Part 4: Riverpod State Management

In Linkbox, providers compose the sync graph. We will do the same with a focused notifier for expenses.

State Model

dart
class ExpenseFeedState {
  final List<ExpenseLocal> items;
  final bool isLoading;
  final bool isLoadingMore;
  final bool hasMore;
  final DateTime? cursor;

  const ExpenseFeedState({
    this.items = const [],
    this.isLoading = false,
    this.isLoadingMore = false,
    this.hasMore = true,
    this.cursor,
  });

  ExpenseFeedState copyWith({
    List<ExpenseLocal>? items,
    bool? isLoading,
    bool? isLoadingMore,
    bool? hasMore,
    DateTime? cursor,
  }) {
    return ExpenseFeedState(
      items: items ?? this.items,
      isLoading: isLoading ?? this.isLoading,
      isLoadingMore: isLoadingMore ?? this.isLoadingMore,
      hasMore: hasMore ?? this.hasMore,
      cursor: cursor ?? this.cursor,
    );
  }
}

Notifier with Pagination + Optimistic Create

dart
@riverpod
class ExpenseFeedController extends _$ExpenseFeedController {
  static const _pageSize = 20;

  @override
  Future<ExpenseFeedState> build(String userId) async {
    final repo = ref.read(expenseRepositoryProvider);
    final first = await repo.fetchPage(userId: userId, limit: _pageSize);

    return ExpenseFeedState(
      items: first,
      hasMore: first.length == _pageSize,
      cursor: first.isEmpty ? null : first.last.spentAt,
    );
  }

  Future<void> loadMore() async {
    final current = state.valueOrNull;
    if (current == null || current.isLoadingMore || !current.hasMore) return;

    state = AsyncData(current.copyWith(isLoadingMore: true));

    final repo = ref.read(expenseRepositoryProvider);
    final next = await repo.fetchPage(
      userId: userId,
      cursor: current.cursor,
      limit: _pageSize,
    );

    final merged = [...current.items, ...next];
    final unique = {for (final e in merged) e.id: e}.values.toList();

    state = AsyncData(
      current.copyWith(
        items: unique,
        isLoadingMore: false,
        hasMore: next.length == _pageSize,
        cursor: next.isEmpty ? current.cursor : next.last.spentAt,
      ),
    );
  }

  Future<void> addExpense({
    required DateTime spentAt,
    required String category,
    required double amount,
    String? note,
  }) async {
    final repo = ref.read(expenseRepositoryProvider);
    final id = const Uuid().v4();

    final draft = ExpenseLocal.create(
      id: id,
      userId: userId,
      spentAt: spentAt,
      category: category,
      amount: amount,
      note: note,
    )
      ..isSynced = false
      ..updatedAt = DateTime.now().toUtc();

    await repo.upsertExpense(draft);
    await ref.read(syncQueueServiceProvider).enqueueUpsert(draft);

    ref.invalidateSelf();
  }
}

Previous: Part 3 - Isar Repository and Pagination

Next: Part 5 - Background Sync Service