Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar на узком. Токены в flutter_secure_storage, на web access живёт в памяти. Интерцептор подставляет токен и делает ровно один refresh на 401. Деньги приходят строками и форматируются через Decimal: парсить их в double значило бы терять копейки ровно там, где бэкенд их бережёт.
161 lines
4.6 KiB
Dart
161 lines
4.6 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:fintracker_api/fintracker_api.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/api/api_client.dart';
|
|
|
|
class TransactionsFilter {
|
|
const TransactionsFilter({
|
|
this.q,
|
|
this.from,
|
|
this.to,
|
|
this.accountId,
|
|
this.categoryId,
|
|
this.flowType,
|
|
});
|
|
|
|
final String? q;
|
|
final DateTime? from;
|
|
final DateTime? to;
|
|
final int? accountId;
|
|
final int? categoryId;
|
|
final FlowType? flowType;
|
|
|
|
bool get isEmpty =>
|
|
q == null && from == null && to == null && accountId == null && categoryId == null && flowType == null;
|
|
|
|
TransactionsFilter copyWith({
|
|
String? Function()? q,
|
|
DateTime? Function()? from,
|
|
DateTime? Function()? to,
|
|
int? Function()? accountId,
|
|
int? Function()? categoryId,
|
|
FlowType? Function()? flowType,
|
|
}) {
|
|
return TransactionsFilter(
|
|
q: q != null ? q() : this.q,
|
|
from: from != null ? from() : this.from,
|
|
to: to != null ? to() : this.to,
|
|
accountId: accountId != null ? accountId() : this.accountId,
|
|
categoryId: categoryId != null ? categoryId() : this.categoryId,
|
|
flowType: flowType != null ? flowType() : this.flowType,
|
|
);
|
|
}
|
|
}
|
|
|
|
class TransactionsState {
|
|
const TransactionsState({
|
|
this.items = const [],
|
|
this.page = 1,
|
|
this.pageSize = 50,
|
|
this.total = 0,
|
|
this.loading = false,
|
|
this.loadingMore = false,
|
|
this.error,
|
|
});
|
|
|
|
final List<TransactionOut> items;
|
|
final int page;
|
|
final int pageSize;
|
|
final int total;
|
|
final bool loading;
|
|
final bool loadingMore;
|
|
final Object? error;
|
|
|
|
bool get hasMore => items.length < total;
|
|
|
|
TransactionsState copyWith({
|
|
List<TransactionOut>? items,
|
|
int? page,
|
|
int? pageSize,
|
|
int? total,
|
|
bool? loading,
|
|
bool? loadingMore,
|
|
Object? error,
|
|
bool clearError = false,
|
|
}) {
|
|
return TransactionsState(
|
|
items: items ?? this.items,
|
|
page: page ?? this.page,
|
|
pageSize: pageSize ?? this.pageSize,
|
|
total: total ?? this.total,
|
|
loading: loading ?? this.loading,
|
|
loadingMore: loadingMore ?? this.loadingMore,
|
|
error: clearError ? null : (error ?? this.error),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Filters + paginated results for Операции. `refresh()` restarts from page 1
|
|
/// (called on build and on every filter change); `loadMore()` appends the
|
|
/// next page for infinite scroll / the "ещё" button.
|
|
class TransactionsController extends Notifier<TransactionsState> {
|
|
TransactionsFilter filter = const TransactionsFilter();
|
|
|
|
@override
|
|
TransactionsState build() {
|
|
Future.microtask(refresh);
|
|
return const TransactionsState(loading: true);
|
|
}
|
|
|
|
Future<void> setFilter(TransactionsFilter Function(TransactionsFilter) update) {
|
|
filter = update(filter);
|
|
return refresh();
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
state = state.copyWith(loading: true, clearError: true);
|
|
try {
|
|
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList(
|
|
from: filter.from,
|
|
to: filter.to,
|
|
accountId: filter.accountId,
|
|
categoryId: filter.categoryId,
|
|
flowType: filter.flowType,
|
|
q: (filter.q == null || filter.q!.isEmpty) ? null : filter.q,
|
|
page: 1,
|
|
pageSize: state.pageSize,
|
|
);
|
|
final page = r.data!;
|
|
state = TransactionsState(
|
|
items: page.items,
|
|
page: page.page,
|
|
pageSize: page.pageSize,
|
|
total: page.total,
|
|
);
|
|
} on DioException catch (e) {
|
|
state = state.copyWith(loading: false, error: e);
|
|
}
|
|
}
|
|
|
|
Future<void> loadMore() async {
|
|
if (state.loading || state.loadingMore || !state.hasMore) return;
|
|
state = state.copyWith(loadingMore: true, clearError: true);
|
|
try {
|
|
final next = state.page + 1;
|
|
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList(
|
|
from: filter.from,
|
|
to: filter.to,
|
|
accountId: filter.accountId,
|
|
categoryId: filter.categoryId,
|
|
flowType: filter.flowType,
|
|
q: (filter.q == null || filter.q!.isEmpty) ? null : filter.q,
|
|
page: next,
|
|
pageSize: state.pageSize,
|
|
);
|
|
final page = r.data!;
|
|
state = state.copyWith(
|
|
items: [...state.items, ...page.items],
|
|
page: page.page,
|
|
total: page.total,
|
|
loadingMore: false,
|
|
);
|
|
} on DioException catch (e) {
|
|
state = state.copyWith(loadingMore: false, error: e);
|
|
}
|
|
}
|
|
}
|
|
|
|
final transactionsControllerProvider =
|
|
NotifierProvider<TransactionsController, TransactionsState>(TransactionsController.new);
|