Appearance
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();
}
}