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:
Dmitry
2026-09-18 13:44:09 +03:00
parent b9c12fa1a1
commit ce0966fca2
99 changed files with 6284 additions and 0 deletions
@@ -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);