feat(app): Flutter-клиент — логин, дашборд, потоки, категории, транзакции, правила
Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar на узком. Токены в flutter_secure_storage, на web access живёт в памяти. Интерцептор подставляет токен и делает ровно один refresh на 401. Деньги приходят строками и форматируются через Decimal: парсить их в double значило бы терять копейки ровно там, где бэкенд их бережёт.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user