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,49 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../auth/auth_controller.dart';
|
||||
|
||||
/// Renders an [AsyncValue]: the data on success, a spinner while loading, and
|
||||
/// a retry-capable error view otherwise. Keeps the loading/error boilerplate
|
||||
/// out of every screen that watches a provider.
|
||||
class AsyncValueView<T> extends StatelessWidget {
|
||||
const AsyncValueView({required this.value, required this.data, this.onRetry, super.key});
|
||||
|
||||
final AsyncValue<T> value;
|
||||
final Widget Function(T data) data;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return value.when(
|
||||
data: data,
|
||||
loading: () => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
error: (error, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text(_message(error), textAlign: TextAlign.center),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(onPressed: onRetry, child: const Text('Повторить')),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _message(Object error) =>
|
||||
error is DioException ? problemMessage(error) : error.toString();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A centered placeholder for a screen or list section with nothing to show yet.
|
||||
class EmptyState extends StatelessWidget {
|
||||
const EmptyState({required this.message, this.icon = Icons.inbox_outlined, super.key});
|
||||
|
||||
final String message;
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 48, color: Theme.of(context).colorScheme.outline),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Currency symbols for the instruments we expect to see. Falls back to the
|
||||
/// ISO code itself (e.g. 'USDT') when we don't have a nicer glyph.
|
||||
const _currencySymbols = {
|
||||
'RUB': '₽',
|
||||
'USD': '\$',
|
||||
'EUR': '€',
|
||||
};
|
||||
|
||||
/// Formats a decimal-string amount (as the API sends it, to avoid double
|
||||
/// rounding errors) with `intl`'s ru_RU rules and a currency symbol.
|
||||
class MoneyText extends StatelessWidget {
|
||||
const MoneyText(this.amount, {required this.currency, super.key, this.style});
|
||||
|
||||
/// The amount as a string, e.g. `'1234.5'` — never a [double].
|
||||
final String amount;
|
||||
final String currency;
|
||||
final TextStyle? style;
|
||||
|
||||
static String format(String amount, String currency) {
|
||||
final value = Decimal.parse(amount).toDouble();
|
||||
final symbol = _currencySymbols[currency] ?? currency;
|
||||
final formatter = NumberFormat.currency(locale: 'ru_RU', symbol: symbol, decimalDigits: 2);
|
||||
return formatter.format(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(format(amount, currency), style: style);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user