style(app): остальные экраны под новый визуальный язык и форматирование

Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
This commit is contained in:
Dmitry
2026-09-19 22:14:27 +03:00
parent 62d36aa3e8
commit 322c60a359
55 changed files with 2158 additions and 1080 deletions
+53 -14
View File
@@ -61,7 +61,10 @@ class CashflowPage extends ConsumerWidget {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
reverse: true, reverse: true,
child: SizedBox( child: SizedBox(
width: (rows.length * 56).toDouble().clamp(320, double.infinity), width: (rows.length * 56).toDouble().clamp(
320,
double.infinity,
),
child: _CashflowChart(rows: rows), child: _CashflowChart(rows: rows),
), ),
), ),
@@ -102,7 +105,11 @@ class _LegendDot extends StatelessWidget {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.bodySmall), Text(label, style: Theme.of(context).textTheme.bodySmall),
], ],
@@ -119,7 +126,11 @@ class _CashflowChart extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maxY = rows.fold<double>( final maxY = rows.fold<double>(
0, 0,
(m, r) => [m, _d(r.incomeRub), _d(r.expenseRub)].reduce((a, b) => a > b ? a : b), (m, r) => [
m,
_d(r.incomeRub),
_d(r.expenseRub),
].reduce((a, b) => a > b ? a : b),
); );
return BarChart( return BarChart(
BarChartData( BarChartData(
@@ -127,9 +138,15 @@ class _CashflowChart extends StatelessWidget {
gridData: const FlGridData(drawVerticalLine: false), gridData: const FlGridData(drawVerticalLine: false),
borderData: FlBorderData(show: false), borderData: FlBorderData(show: false),
titlesData: FlTitlesData( titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), topTitles: const AxisTitles(
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), sideTitles: SideTitles(showTitles: false),
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), ),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles( bottomTitles: AxisTitles(
sideTitles: SideTitles( sideTitles: SideTitles(
showTitles: true, showTitles: true,
@@ -138,7 +155,10 @@ class _CashflowChart extends StatelessWidget {
if (i < 0 || i >= rows.length) return const SizedBox.shrink(); if (i < 0 || i >= rows.length) return const SizedBox.shrink();
return Padding( return Padding(
padding: const EdgeInsets.only(top: 6), padding: const EdgeInsets.only(top: 6),
child: Text(ruMonthYearShort(rows[i].month), style: const TextStyle(fontSize: 10)), child: Text(
ruMonthYearShort(rows[i].month),
style: const TextStyle(fontSize: 10),
),
); );
}, },
), ),
@@ -160,8 +180,16 @@ class _CashflowChart extends StatelessWidget {
BarChartGroupData( BarChartGroupData(
x: i, x: i,
barRods: [ barRods: [
BarChartRodData(toY: _d(rows[i].incomeRub), color: _incomeColor, width: 8), BarChartRodData(
BarChartRodData(toY: _d(rows[i].expenseRub), color: _expenseColor, width: 8), toY: _d(rows[i].incomeRub),
color: _incomeColor,
width: 8,
),
BarChartRodData(
toY: _d(rows[i].expenseRub),
color: _expenseColor,
width: 8,
),
], ],
barsSpace: 2, barsSpace: 2,
), ),
@@ -188,13 +216,20 @@ class _Table extends StatelessWidget {
DataColumn(label: Text('Расход', style: headerStyle), numeric: true), DataColumn(label: Text('Расход', style: headerStyle), numeric: true),
DataColumn(label: Text('Базовые', style: headerStyle), numeric: true), DataColumn(label: Text('Базовые', style: headerStyle), numeric: true),
DataColumn(label: Text('Разовые', style: headerStyle), numeric: true), DataColumn(label: Text('Разовые', style: headerStyle), numeric: true),
DataColumn(label: Text('В сбережения', style: headerStyle), numeric: true), DataColumn(
DataColumn(label: Text('Норма сбер., %', style: headerStyle), numeric: true), label: Text('В сбережения', style: headerStyle),
numeric: true,
),
DataColumn(
label: Text('Норма сбер., %', style: headerStyle),
numeric: true,
),
], ],
rows: [ rows: [
for (final r in rows.reversed) for (final r in rows.reversed)
DataRow( DataRow(
onSelectChanged: (_) => context.go('/categories?month=${monthKey(r.month)}'), onSelectChanged: (_) =>
context.go('/categories?month=${monthKey(r.month)}'),
cells: [ cells: [
DataCell(Text(ruMonthYearShort(r.month))), DataCell(Text(ruMonthYearShort(r.month))),
DataCell(MoneyText(r.incomeRub, currency: 'RUB')), DataCell(MoneyText(r.incomeRub, currency: 'RUB')),
@@ -202,9 +237,13 @@ class _Table extends StatelessWidget {
DataCell(MoneyText(r.baselineRub, currency: 'RUB')), DataCell(MoneyText(r.baselineRub, currency: 'RUB')),
DataCell(MoneyText(r.oneOffRub, currency: 'RUB')), DataCell(MoneyText(r.oneOffRub, currency: 'RUB')),
DataCell(MoneyText(r.savingsTransferRub, currency: 'RUB')), DataCell(MoneyText(r.savingsTransferRub, currency: 'RUB')),
DataCell(Text(r.savingsRate == null DataCell(
Text(
r.savingsRate == null
? '' ? ''
: '${(_d(r.savingsRate!) * 100).toStringAsFixed(1)}%')), : '${(_d(r.savingsRate!) * 100).toStringAsFixed(1)}%',
),
),
], ],
), ),
], ],
+10 -3
View File
@@ -5,7 +5,14 @@ import '../../core/api/api_client.dart';
import '../../core/cache/cached.dart'; import '../../core/cache/cached.dart';
/// The last 24 months, oldest first (as the API returns them). See `docs/ai/offline-cache.md`. /// The last 24 months, oldest first (as the API returns them). See `docs/ai/offline-cache.md`.
final cashflowMonthly24Provider = FutureProvider.autoDispose<Cached<List<CashFlowMonth>>>((ref) async { final cashflowMonthly24Provider =
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 24); FutureProvider.autoDispose<Cached<List<CashFlowMonth>>>((ref) async {
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); final r = await ref
.watch(apiProvider)
.getCashflowApi()
.cashflowMonthly(months: 24);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}); });
@@ -18,20 +18,29 @@ class _Group {
final String rootName; final String rootName;
final List<SpendingRow> rows = []; final List<SpendingRow> rows = [];
Decimal get total => rows.fold(Decimal.zero, (a, r) => a + Decimal.parse(r.amountRub)); Decimal get total =>
rows.fold(Decimal.zero, (a, r) => a + Decimal.parse(r.amountRub));
} }
List<_Group> _group(List<SpendingRow> rows) { List<_Group> _group(List<SpendingRow> rows) {
final byRoot = <int?, _Group>{}; final byRoot = <int?, _Group>{};
for (final r in rows) { for (final r in rows) {
final key = r.categoryId == null ? null : (r.rootCategoryId ?? r.categoryId); final key = r.categoryId == null
? null
: (r.rootCategoryId ?? r.categoryId);
final group = byRoot.putIfAbsent( final group = byRoot.putIfAbsent(
key, key,
() => _Group(key, key == null ? 'Без категории' : (r.rootCategoryName ?? r.categoryName ?? '')), () => _Group(
key,
key == null
? 'Без категории'
: (r.rootCategoryName ?? r.categoryName ?? ''),
),
); );
group.rows.add(r); group.rows.add(r);
} }
final groups = byRoot.values.toList()..sort((a, b) => b.total.compareTo(a.total)); final groups = byRoot.values.toList()
..sort((a, b) => b.total.compareTo(a.total));
return groups; return groups;
} }
@@ -51,7 +60,9 @@ class _CategoriesPageState extends ConsumerState<CategoriesPage> {
super.initState(); super.initState();
final initial = widget.initialMonth; final initial = widget.initialMonth;
if (initial != null) { if (initial != null) {
Future.microtask(() => ref.read(selectedSpendingMonthProvider.notifier).state = initial); Future.microtask(
() => ref.read(selectedSpendingMonthProvider.notifier).state = initial,
);
} }
} }
@@ -66,8 +77,9 @@ class _CategoriesPageState extends ConsumerState<CategoriesPage> {
initialDatePickerMode: DatePickerMode.year, initialDatePickerMode: DatePickerMode.year,
); );
if (picked != null) { if (picked != null) {
ref.read(selectedSpendingMonthProvider.notifier).state = ref.read(selectedSpendingMonthProvider.notifier).state = monthKey(
monthKey(DateTime(picked.year, picked.month)); DateTime(picked.year, picked.month),
);
} }
} }
@@ -134,7 +146,10 @@ class _CategoriesPageState extends ConsumerState<CategoriesPage> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('Всего расходов', style: Theme.of(context).textTheme.bodyLarge), Text(
'Всего расходов',
style: Theme.of(context).textTheme.bodyLarge,
),
MoneyText( MoneyText(
total.toString(), total.toString(),
currency: 'RUB', currency: 'RUB',
@@ -144,7 +159,8 @@ class _CategoriesPageState extends ConsumerState<CategoriesPage> {
), ),
), ),
const Divider(), const Divider(),
for (final g in groups) _GroupTile(group: g, maxTotal: maxTotal), for (final g in groups)
_GroupTile(group: g, maxTotal: maxTotal),
], ],
); );
}, },
@@ -163,7 +179,8 @@ class _GroupTile extends StatelessWidget {
final Decimal maxTotal; final Decimal maxTotal;
bool get _flat => bool get _flat =>
group.rows.length == 1 && (group.rootId == null || group.rows.first.categoryId == group.rootId); group.rows.length == 1 &&
(group.rootId == null || group.rows.first.categoryId == group.rootId);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -176,16 +193,26 @@ class _GroupTile extends StatelessWidget {
); );
} }
final children = [...group.rows] final children = [...group.rows]
..sort((a, b) => Decimal.parse(b.amountRub).compareTo(Decimal.parse(a.amountRub))); ..sort(
(a, b) =>
Decimal.parse(b.amountRub).compareTo(Decimal.parse(a.amountRub)),
);
return ExpansionTile( return ExpansionTile(
tilePadding: EdgeInsets.zero, tilePadding: EdgeInsets.zero,
title: _CategoryBar(name: group.rootName, amount: group.total, maxAmount: maxTotal, bold: true), title: _CategoryBar(
name: group.rootName,
amount: group.total,
maxAmount: maxTotal,
bold: true,
),
children: [ children: [
for (final r in children) for (final r in children)
Padding( Padding(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
child: _CategoryBar( child: _CategoryBar(
name: r.categoryId == group.rootId ? 'Без подкатегории' : (r.categoryName ?? ''), name: r.categoryId == group.rootId
? 'Без подкатегории'
: (r.categoryName ?? ''),
amount: Decimal.parse(r.amountRub), amount: Decimal.parse(r.amountRub),
maxAmount: group.total, maxAmount: group.total,
bold: false, bold: false,
@@ -240,7 +267,9 @@ class _CategoryBar extends StatelessWidget {
Container( Container(
height: 6, height: 6,
width: constraints.maxWidth * ratio, width: constraints.maxWidth * ratio,
color: bold ? ChartColors.expense : ChartColors.expense.withValues(alpha: 0.6), color: bold
? ChartColors.expense
: ChartColors.expense.withValues(alpha: 0.6),
), ),
], ],
), ),
+13 -5
View File
@@ -7,7 +7,9 @@ import '../../core/utils/ru_date.dart';
/// Flat ZenMoney tag tree — the client nests it by `parent_id` where needed. Shared with /// Flat ZenMoney tag tree — the client nests it by `parent_id` where needed. Shared with
/// Транзакции; not wrapped in `Cached` since it is a lookup, not a screen's own primary read. /// Транзакции; not wrapped in `Cached` since it is a lookup, not a screen's own primary read.
final categoriesListProvider = FutureProvider.autoDispose<List<CategoryOut>>((ref) async { final categoriesListProvider = FutureProvider.autoDispose<List<CategoryOut>>((
ref,
) async {
final r = await ref.watch(apiProvider).getCategoriesApi().categoriesList(); final r = await ref.watch(apiProvider).getCategoriesApi().categoriesList();
return r.data ?? const []; return r.data ?? const [];
}); });
@@ -20,10 +22,16 @@ final categoryNamesProvider = Provider.autoDispose<Map<int, String>>((ref) {
/// Spending by category for one month (`YYYY-MM`); null means "the latest month". See /// Spending by category for one month (`YYYY-MM`); null means "the latest month". See
/// `docs/ai/offline-cache.md`. /// `docs/ai/offline-cache.md`.
final spendingProvider = final spendingProvider = FutureProvider.autoDispose
FutureProvider.autoDispose.family<Cached<List<SpendingRow>>, String?>((ref, month) async { .family<Cached<List<SpendingRow>>, String?>((ref, month) async {
final r = await ref.watch(apiProvider).getCashflowApi().cashflowSpending(month: month); final r = await ref
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); .watch(apiProvider)
.getCashflowApi()
.cashflowSpending(month: month);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}); });
/// The month currently selected on the Категории screen, `YYYY-MM`. /// The month currently selected on the Категории screen, `YYYY-MM`.
+4 -1
View File
@@ -142,7 +142,10 @@ class GoalsApi {
} }
Future<Goal> patch(int id, Map<String, dynamic> changes) async { Future<Goal> patch(int id, Map<String, dynamic> changes) async {
final r = await _dio.patch<Map<String, dynamic>>('$_base/$id', data: changes); final r = await _dio.patch<Map<String, dynamic>>(
'$_base/$id',
data: changes,
);
return Goal.fromJson(r.data ?? const {}); return Goal.fromJson(r.data ?? const {});
} }
+21 -7
View File
@@ -19,7 +19,8 @@ const goalBasisLabels = {
const goalBasisDescriptions = { const goalBasisDescriptions = {
'xirr': 'Прогноз построен на фактической доходности портфеля (XIRR).', 'xirr': 'Прогноз построен на фактической доходности портфеля (XIRR).',
'contribution': 'Прогноз построен на регулярных взносах, без учёта доходности.', 'contribution':
'Прогноз построен на регулярных взносах, без учёта доходности.',
'none': 'Данных для прогноза нет: ни доходности, ни истории взносов.', 'none': 'Данных для прогноза нет: ни доходности, ни истории взносов.',
}; };
@@ -58,12 +59,15 @@ class GoalCard extends ConsumerWidget {
Text( Text(
[ [
'цель ${MoneyText.format(goal.targetAmount, goal.currency)}', 'цель ${MoneyText.format(goal.targetAmount, goal.currency)}',
if (goal.targetDate != null) 'к ${ruDate(goal.targetDate!)}', if (goal.targetDate != null)
'к ${ruDate(goal.targetDate!)}',
if (goal.monthlyContribution != null) if (goal.monthlyContribution != null)
'взнос ${MoneyText.format(goal.monthlyContribution!, goal.currency)}/мес', 'взнос ${MoneyText.format(goal.monthlyContribution!, goal.currency)}/мес',
if (goal.archived) 'в архиве', if (goal.archived) 'в архиве',
].join(' · '), ].join(' · '),
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
), ),
], ],
), ),
@@ -86,7 +90,8 @@ class GoalCard extends ConsumerWidget {
AsyncValueView( AsyncValueView(
value: progress, value: progress,
onRetry: () => ref.invalidate(goalProgressProvider(goal.id)), onRetry: () => ref.invalidate(goalProgressProvider(goal.id)),
data: (cached) => GoalProgressView(goal: goal, progress: cached.data), data: (cached) =>
GoalProgressView(goal: goal, progress: cached.data),
), ),
], ],
), ),
@@ -98,7 +103,11 @@ class GoalCard extends ConsumerWidget {
/// The progress body, split out of [GoalCard] so it can be rendered (and tested) without a /// The progress body, split out of [GoalCard] so it can be rendered (and tested) without a
/// provider container. /// provider container.
class GoalProgressView extends StatelessWidget { class GoalProgressView extends StatelessWidget {
const GoalProgressView({required this.goal, required this.progress, super.key}); const GoalProgressView({
required this.goal,
required this.progress,
super.key,
});
final Goal goal; final Goal goal;
final GoalProgress progress; final GoalProgress progress;
@@ -150,7 +159,9 @@ class GoalProgressView extends StatelessWidget {
Icon( Icon(
unreachable ? Icons.trending_flat : Icons.flag_outlined, unreachable ? Icons.trending_flat : Icons.flag_outlined,
size: 18, size: 18,
color: unreachable ? theme.colorScheme.error : theme.colorScheme.primary, color: unreachable
? theme.colorScheme.error
: theme.colorScheme.primary,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
@@ -192,7 +203,10 @@ class GoalProgressView extends StatelessWidget {
), ),
), ),
if (progress.asOf != null) if (progress.asOf != null)
Text('на ${ruDate(progress.asOf!)}', style: theme.textTheme.bodySmall), Text(
'на ${ruDate(progress.asOf!)}',
style: theme.textTheme.bodySmall,
),
], ],
), ),
], ],
+34 -14
View File
@@ -20,9 +20,12 @@ class GoalEditDialog extends ConsumerStatefulWidget {
class _GoalEditDialogState extends ConsumerState<GoalEditDialog> { class _GoalEditDialogState extends ConsumerState<GoalEditDialog> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
late final _name = TextEditingController(text: widget.initial?.name ?? ''); late final _name = TextEditingController(text: widget.initial?.name ?? '');
late final _amount = TextEditingController(text: widget.initial?.targetAmount ?? ''); late final _amount = TextEditingController(
late final _contribution = text: widget.initial?.targetAmount ?? '',
TextEditingController(text: widget.initial?.monthlyContribution ?? ''); );
late final _contribution = TextEditingController(
text: widget.initial?.monthlyContribution ?? '',
);
late final _note = TextEditingController(text: widget.initial?.note ?? ''); late final _note = TextEditingController(text: widget.initial?.note ?? '');
late String _scope = widget.initial?.scope ?? 'all'; late String _scope = widget.initial?.scope ?? 'all';
late DateTime? _targetDate = widget.initial?.targetDate; late DateTime? _targetDate = widget.initial?.targetDate;
@@ -71,7 +74,8 @@ class _GoalEditDialogState extends ConsumerState<GoalEditDialog> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: scopes.any((s) => s.scope == _scope) || _scope == 'all' initialValue:
scopes.any((s) => s.scope == _scope) || _scope == 'all'
? _scope ? _scope
: 'all', : 'all',
decoration: const InputDecoration(labelText: 'Что считаем'), decoration: const InputDecoration(labelText: 'Что считаем'),
@@ -86,14 +90,20 @@ class _GoalEditDialogState extends ConsumerState<GoalEditDialog> {
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
controller: _amount, controller: _amount,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(
decoration: const InputDecoration(labelText: 'Целевая сумма, ₽'), decimal: true,
),
decoration: const InputDecoration(
labelText: 'Целевая сумма, ₽',
),
validator: _decimalValidator, validator: _decimalValidator,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
controller: _contribution, controller: _contribution,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Ежемесячный взнос, ₽', labelText: 'Ежемесячный взнос, ₽',
helperText: 'необязательно', helperText: 'необязательно',
@@ -104,20 +114,25 @@ class _GoalEditDialogState extends ConsumerState<GoalEditDialog> {
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Text(_targetDate == null child: Text(
_targetDate == null
? 'Целевая дата не задана' ? 'Целевая дата не задана'
: 'Целевая дата: ${ruDate(_targetDate!)}'), : 'Целевая дата: ${ruDate(_targetDate!)}',
),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
final now = DateTime.now(); final now = DateTime.now();
final picked = await showDatePicker( final picked = await showDatePicker(
context: context, context: context,
initialDate: _targetDate ?? DateTime(now.year + 3, now.month, now.day), initialDate:
_targetDate ??
DateTime(now.year + 3, now.month, now.day),
firstDate: DateTime(now.year - 1), firstDate: DateTime(now.year - 1),
lastDate: DateTime(now.year + 50), lastDate: DateTime(now.year + 50),
); );
if (picked != null) setState(() => _targetDate = picked); if (picked != null)
setState(() => _targetDate = picked);
}, },
child: const Text('Выбрать'), child: const Text('Выбрать'),
), ),
@@ -147,11 +162,15 @@ class _GoalEditDialogState extends ConsumerState<GoalEditDialog> {
), ),
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Отмена')), TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
if (!(_formKey.currentState?.validate() ?? false)) return; if (!(_formKey.currentState?.validate() ?? false)) return;
Navigator.of(context).pop(Goal( Navigator.of(context).pop(
Goal(
id: widget.initial?.id ?? 0, id: widget.initial?.id ?? 0,
name: _name.text.trim(), name: _name.text.trim(),
scope: _scope, scope: _scope,
@@ -161,7 +180,8 @@ class _GoalEditDialogState extends ConsumerState<GoalEditDialog> {
monthlyContribution: _decimal(_contribution.text), monthlyContribution: _decimal(_contribution.text),
note: _note.text.trim().isEmpty ? null : _note.text.trim(), note: _note.text.trim().isEmpty ? null : _note.text.trim(),
archived: _archived, archived: _archived,
)); ),
);
}, },
child: const Text('Сохранить'), child: const Text('Сохранить'),
), ),
+21 -7
View File
@@ -23,7 +23,8 @@ class _GoalsPageState extends ConsumerState<GoalsPage> {
bool _showArchived = false; bool _showArchived = false;
void _snack(String message) => void _snack(String message) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
Future<void> _create() async { Future<void> _create() async {
final goal = await showDialog<Goal>( final goal = await showDialog<Goal>(
@@ -62,8 +63,14 @@ class _GoalsPageState extends ConsumerState<GoalsPage> {
title: const Text('Удалить цель?'), title: const Text('Удалить цель?'),
content: Text('«${goal.name}» будет удалена безвозвратно.'), content: Text('«${goal.name}» будет удалена безвозвратно.'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Отмена')), TextButton(
FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Удалить')), onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Удалить'),
),
], ],
), ),
); );
@@ -82,10 +89,13 @@ class _GoalsPageState extends ConsumerState<GoalsPage> {
final goals = ref.watch(goalsProvider); final goals = ref.watch(goalsProvider);
// Every card's own progress fetch counts toward the one banner too — a fresh list with a // Every card's own progress fetch counts toward the one banner too — a fresh list with a
// stale progress card is still an offline dashboard, just not a visibly empty one. // stale progress card is still an offline dashboard, just not a visibly empty one.
final goalIds = [for (final g in goals.valueOrNull?.data ?? const <Goal>[]) g.id]; final goalIds = [
for (final g in goals.valueOrNull?.data ?? const <Goal>[]) g.id,
];
final stale = oldestFetch([ final stale = oldestFetch([
goals.valueOrNull?.fetchedAt, goals.valueOrNull?.fetchedAt,
for (final id in goalIds) ref.watch(goalProgressProvider(id)).valueOrNull?.fetchedAt, for (final id in goalIds)
ref.watch(goalProgressProvider(id)).valueOrNull?.fetchedAt,
]); ]);
return Scaffold( return Scaffold(
@@ -94,7 +104,9 @@ class _GoalsPageState extends ConsumerState<GoalsPage> {
actions: [ actions: [
IconButton( IconButton(
tooltip: _showArchived ? 'Скрыть архив' : 'Показать архив', tooltip: _showArchived ? 'Скрыть архив' : 'Показать архив',
icon: Icon(_showArchived ? Icons.inventory_2 : Icons.inventory_2_outlined), icon: Icon(
_showArchived ? Icons.inventory_2 : Icons.inventory_2_outlined,
),
onPressed: () => setState(() => _showArchived = !_showArchived), onPressed: () => setState(() => _showArchived = !_showArchived),
), ),
IconButton( IconButton(
@@ -116,7 +128,9 @@ class _GoalsPageState extends ConsumerState<GoalsPage> {
onRetry: () => ref.invalidate(goalsProvider), onRetry: () => ref.invalidate(goalsProvider),
data: (cached) { data: (cached) {
final all = cached.data; final all = cached.data;
final rows = _showArchived ? all : all.where((g) => !g.archived).toList(); final rows = _showArchived
? all
: all.where((g) => !g.archived).toList();
if (rows.isEmpty) { if (rows.isEmpty) {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
+8 -5
View File
@@ -4,16 +4,19 @@ import '../../core/api/api_client.dart';
import '../../core/cache/cached.dart'; import '../../core/cache/cached.dart';
import 'data/goals_api.dart'; import 'data/goals_api.dart';
final goalsApiProvider = Provider<GoalsApi>((ref) => GoalsApi(ref.watch(apiProvider).dio)); final goalsApiProvider = Provider<GoalsApi>(
(ref) => GoalsApi(ref.watch(apiProvider).dio),
);
/// See `docs/ai/offline-cache.md`. /// See `docs/ai/offline-cache.md`.
final goalsProvider = final goalsProvider = FutureProvider.autoDispose<Cached<List<Goal>>>(
FutureProvider.autoDispose<Cached<List<Goal>>>((ref) => ref.watch(goalsApiProvider).list()); (ref) => ref.watch(goalsApiProvider).list(),
);
/// Progress is computed server-side and refetched per goal — the client never projects /// Progress is computed server-side and refetched per goal — the client never projects
/// anything itself. /// anything itself.
final goalProgressProvider = final goalProgressProvider = FutureProvider.autoDispose
FutureProvider.autoDispose.family<Cached<GoalProgress>, int>((ref, id) async { .family<Cached<GoalProgress>, int>((ref, id) async {
return ref.watch(goalsApiProvider).progress(id); return ref.watch(goalsApiProvider).progress(id);
}); });
@@ -52,14 +52,19 @@ class _DataQualityTile extends StatelessWidget {
margin: const EdgeInsets.only(top: 4, right: 10), margin: const EdgeInsets.only(top: 4, right: 10),
width: 10, width: 10,
height: 10, height: 10,
decoration: BoxDecoration(color: severityColor(row.severity), shape: BoxShape.circle), decoration: BoxDecoration(
color: severityColor(row.severity),
shape: BoxShape.circle,
),
), ),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('${row.checkName} (${row.count})', Text(
style: Theme.of(context).textTheme.titleSmall), '${row.checkName} (${row.count})',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(row.detail), Text(row.detail),
], ],
+46 -14
View File
@@ -16,12 +16,16 @@ import 'data_quality_list.dart';
final _dateFmt = DateFormat('dd.MM.yyyy HH:mm'); final _dateFmt = DateFormat('dd.MM.yyyy HH:mm');
final syncStatusProvider = FutureProvider.autoDispose<List<SourceStatus>>((ref) async { final syncStatusProvider = FutureProvider.autoDispose<List<SourceStatus>>((
ref,
) async {
final r = await ref.watch(apiProvider).getSyncApi().syncStatus(); final r = await ref.watch(apiProvider).getSyncApi().syncStatus();
return r.data ?? const []; return r.data ?? const [];
}); });
final syncRunsProvider = FutureProvider.autoDispose<List<SyncRunOut>>((ref) async { final syncRunsProvider = FutureProvider.autoDispose<List<SyncRunOut>>((
ref,
) async {
final r = await ref.watch(apiProvider).getSyncApi().syncRuns(limit: 20); final r = await ref.watch(apiProvider).getSyncApi().syncRuns(limit: 20);
return r.data ?? const []; return r.data ?? const [];
}); });
@@ -74,7 +78,8 @@ class _HealthPageState extends ConsumerState<HealthPage> {
await ref.read(apiProvider).getSyncApi().syncTrigger(source_: source); await ref.read(apiProvider).getSyncApi().syncTrigger(source_: source);
} on DioException catch (e) { } on DioException catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally { } finally {
ref.invalidate(syncStatusProvider); ref.invalidate(syncStatusProvider);
ref.invalidate(syncRunsProvider); ref.invalidate(syncRunsProvider);
@@ -114,7 +119,10 @@ class _HealthPageState extends ConsumerState<HealthPage> {
body: TabBarView( body: TabBarView(
children: [ children: [
_SourcesTab(onRefresh: _refreshAll, onTrigger: _trigger), _SourcesTab(onRefresh: _refreshAll, onTrigger: _trigger),
_QualityTab(dataQuality: dataQuality, onRefresh: () async => ref.invalidate(dataQualityProvider)), _QualityTab(
dataQuality: dataQuality,
onRefresh: () async => ref.invalidate(dataQualityProvider),
),
], ],
), ),
), ),
@@ -149,17 +157,26 @@ class _SourcesTab extends ConsumerWidget {
message: 'Источники данных ещё не подключены (фаза 1: ZenMoney, ЦБ).', message: 'Источники данных ещё не подключены (фаза 1: ZenMoney, ЦБ).',
) )
: Column( : Column(
children: [for (final s in rows) _SourceCard(source: s, onTrigger: onTrigger)], children: [
for (final s in rows)
_SourceCard(source: s, onTrigger: onTrigger),
],
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text('Последние запуски', style: Theme.of(context).textTheme.titleMedium), Text(
'Последние запуски',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8), const SizedBox(height: 8),
AsyncValueView( AsyncValueView(
value: runs, value: runs,
onRetry: () => ref.invalidate(syncRunsProvider), onRetry: () => ref.invalidate(syncRunsProvider),
data: (rows) => rows.isEmpty data: (rows) => rows.isEmpty
? const EmptyState(icon: Icons.history, message: 'Запусков ещё не было.') ? const EmptyState(
icon: Icons.history,
message: 'Запусков ещё не было.',
)
: Column(children: [for (final r in rows) _RunTile(run: r)]), : Column(children: [for (final r in rows) _RunTile(run: r)]),
), ),
], ],
@@ -217,10 +234,14 @@ class _SourceCard extends StatelessWidget {
), ),
subtitle: Padding( subtitle: Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text([ child: Text(
if (source.lastRunAt != null) 'запуск ${_dateFmt.format(source.lastRunAt!.toLocal())}', [
if (source.cursor != null) 'курсор ${_shortCursor(source.cursor!)}', if (source.lastRunAt != null)
].join(' · ')), 'запуск ${_dateFmt.format(source.lastRunAt!.toLocal())}',
if (source.cursor != null)
'курсор ${_shortCursor(source.cursor!)}',
].join(' · '),
),
), ),
trailing: IconButton( trailing: IconButton(
tooltip: 'Запустить синхронизацию', tooltip: 'Запустить синхронизацию',
@@ -277,7 +298,12 @@ class _RunTile extends StatelessWidget {
children: [ children: [
content, content,
const SizedBox(height: 8), const SizedBox(height: 8),
Text(run.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), Text(
run.error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
], ],
), ),
), ),
@@ -295,7 +321,10 @@ class _RunTile extends StatelessWidget {
_runTitle(context), _runTitle(context),
Padding( Padding(
padding: const EdgeInsets.only(top: 2, bottom: 6), padding: const EdgeInsets.only(top: 2, bottom: 6),
child: Text(subtitle, style: Theme.of(context).textTheme.bodySmall), child: Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall,
),
), ),
content, content,
], ],
@@ -317,7 +346,10 @@ class _RunTile extends StatelessWidget {
Widget _statusChip(BuildContext context, RunStatus? status) { Widget _statusChip(BuildContext context, RunStatus? status) {
if (status == null) { if (status == null) {
return const Chip(visualDensity: VisualDensity.compact, label: Text('нет данных')); return const Chip(
visualDensity: VisualDensity.compact,
label: Text('нет данных'),
);
} }
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final (label, color) = switch (status) { final (label, color) = switch (status) {
+35 -17
View File
@@ -17,14 +17,20 @@ import '../../pending/data/pending_api.dart';
/// A candidate account for an import whose `account_id` the server could not resolve. /// A candidate account for an import whose `account_id` the server could not resolve.
class AccountSuggestion { class AccountSuggestion {
const AccountSuggestion({required this.id, required this.name, this.broker, this.sourceId}); const AccountSuggestion({
required this.id,
required this.name,
this.broker,
this.sourceId,
});
final int id; final int id;
final String name; final String name;
final String? broker; final String? broker;
final String? sourceId; final String? sourceId;
static AccountSuggestion fromJson(Map<String, dynamic> json) => AccountSuggestion( static AccountSuggestion fromJson(Map<String, dynamic> json) =>
AccountSuggestion(
id: asInt(json['id'])!, id: asInt(json['id'])!,
name: asString(json['name']) ?? '#${json['id']}', name: asString(json['name']) ?? '#${json['id']}',
broker: asString(json['broker']), broker: asString(json['broker']),
@@ -294,20 +300,24 @@ class ImportPreview {
accountExternalId: asString(json['account_external_id']), accountExternalId: asString(json['account_external_id']),
accountId: asInt(json['account_id']), accountId: asInt(json['account_id']),
accountName: asString(json['account_name']), accountName: asString(json['account_name']),
accountSuggestions: accountSuggestions: asList(json['account_suggestions'])
asList(json['account_suggestions']).map(AccountSuggestion.fromJson).toList(), .map(AccountSuggestion.fromJson)
.toList(),
periodFrom: asDate(json['period_from']), periodFrom: asDate(json['period_from']),
periodTo: asDate(json['period_to']), periodTo: asDate(json['period_to']),
uploadedAt: asDate(json['uploaded_at']), uploadedAt: asDate(json['uploaded_at']),
committedAt: asDate(json['committed_at']), committedAt: asDate(json['committed_at']),
counts: ImportCounts.fromJson(asMap(json['counts'])), counts: ImportCounts.fromJson(asMap(json['counts'])),
pendingInstruments: pendingInstruments: asList(json['pending_instruments'])
asList(json['pending_instruments']).map(PendingInstrument.fromJson).toList(), .map(PendingInstrument.fromJson)
.toList(),
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])), reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
warnings: json['warnings'] is List warnings: json['warnings'] is List
? (json['warnings'] as List).map((e) => e.toString()).toList() ? (json['warnings'] as List).map((e) => e.toString()).toList()
: const [], : const [],
sampleEvents: asList(json['sample_events']).map(SampleEvent.fromJson).toList(), sampleEvents: asList(json['sample_events'])
.map(SampleEvent.fromJson)
.toList(),
); );
} }
@@ -366,14 +376,14 @@ class ImportsApi {
static const _base = '/api/v1/imports'; static const _base = '/api/v1/imports';
Future<List<ImportPreview>> list({int limit = 50, int offset = 0, String? status}) async { Future<List<ImportPreview>> list({
int limit = 50,
int offset = 0,
String? status,
}) async {
final r = await _dio.get<List<dynamic>>( final r = await _dio.get<List<dynamic>>(
_base, _base,
queryParameters: { queryParameters: {'limit': limit, 'offset': offset, 'status': ?status},
'limit': limit,
'offset': offset,
'status': ?status,
},
); );
return (r.data ?? const []) return (r.data ?? const [])
.map((e) => ImportPreview.fromJson(Map<String, dynamic>.from(e as Map))) .map((e) => ImportPreview.fromJson(Map<String, dynamic>.from(e as Map)))
@@ -385,7 +395,11 @@ class ImportsApi {
return ImportPreview.fromJson(r.data!); return ImportPreview.fromJson(r.data!);
} }
Future<ImportPreview> upload(PickedReport report, {int? accountId, String? parser}) async { Future<ImportPreview> upload(
PickedReport report, {
int? accountId,
String? parser,
}) async {
final bytes = report.bytes; final bytes = report.bytes;
final form = FormData.fromMap({ final form = FormData.fromMap({
'file': bytes != null 'file': bytes != null
@@ -404,11 +418,14 @@ class ImportsApi {
bool confirmDuplicates = false, bool confirmDuplicates = false,
bool dryRun = false, bool dryRun = false,
}) async { }) async {
final r = await _dio.post<Map<String, dynamic>>('$_base/$id/commit', data: { final r = await _dio.post<Map<String, dynamic>>(
'$_base/$id/commit',
data: {
'account_id': ?accountId, 'account_id': ?accountId,
'confirm_duplicates': confirmDuplicates, 'confirm_duplicates': confirmDuplicates,
'dry_run': dryRun, 'dry_run': dryRun,
}); },
);
return ImportResult.fromJson(r.data ?? const {}); return ImportResult.fromJson(r.data ?? const {});
} }
@@ -438,4 +455,5 @@ List<Map<String, dynamic>> asList(Object? v) => v is List
? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList() ? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
: const []; : const [];
Map<String, dynamic>? asMap(Object? v) => v is Map ? Map<String, dynamic>.from(v) : null; Map<String, dynamic>? asMap(Object? v) =>
v is Map ? Map<String, dynamic>.from(v) : null;
@@ -34,4 +34,6 @@ class FilePickerReportPicker implements ReportPicker {
} }
} }
final reportPickerProvider = Provider<ReportPicker>((ref) => const FilePickerReportPicker()); final reportPickerProvider = Provider<ReportPicker>(
(ref) => const FilePickerReportPicker(),
);
+128 -48
View File
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../core/utils/ru_date.dart'; import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
import '../accounts/account_create_dialog.dart';
import '../portfolio/labels.dart' show eventKindLabels, formatQty; import '../portfolio/labels.dart' show eventKindLabels, formatQty;
import 'data/imports_api.dart'; import 'data/imports_api.dart';
import 'labels.dart'; import 'labels.dart';
@@ -31,14 +32,17 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
ImportResult? _result; ImportResult? _result;
void _snack(String message) => void _snack(String message) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
Future<void> _commit(ImportPreview preview) async { Future<void> _commit(ImportPreview preview) async {
final accountId = preview.accountId ?? _accountChoice; final accountId = preview.accountId ?? _accountChoice;
if (accountId == null) return; if (accountId == null) return;
setState(() => _busy = true); setState(() => _busy = true);
try { try {
final result = await ref.read(importsApiProvider).commit( final result = await ref
.read(importsApiProvider)
.commit(
preview.id, preview.id,
accountId: preview.accountId == null ? accountId : null, accountId: preview.accountId == null ? accountId : null,
confirmDuplicates: _confirmDuplicates, confirmDuplicates: _confirmDuplicates,
@@ -49,8 +53,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
invalidateLedgerDependents(ref); invalidateLedgerDependents(ref);
ref.invalidate(importsListProvider); ref.invalidate(importsListProvider);
ref.invalidate(importPreviewProvider(preview.id)); ref.invalidate(importPreviewProvider(preview.id));
_snack('Импортировано: создано ${result.eventsCreated}, ' _snack(
'обновлено ${result.eventsUpdated}, пропущено ${result.eventsSkipped}'); 'Импортировано: создано ${result.eventsCreated}, '
'обновлено ${result.eventsUpdated}, пропущено ${result.eventsSkipped}',
);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
_snack(importErrorMessage(e)); _snack(importErrorMessage(e));
@@ -64,13 +70,19 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Удалить импорт?'), title: const Text('Удалить импорт?'),
content: Text('Файл «${preview.filename}» и разобранные строки будут удалены. ' content: Text(
'События в леджере не создавались, так что удалять нечего.'), 'Файл «${preview.filename}» и разобранные строки будут удалены. '
'События в леджере не создавались, так что удалять нечего.',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(false), child: const Text('Отмена')), onPressed: () => Navigator.of(context).pop(false),
child: const Text('Отмена'),
),
FilledButton( FilledButton(
onPressed: () => Navigator.of(context).pop(true), child: const Text('Удалить')), onPressed: () => Navigator.of(context).pop(true),
child: const Text('Удалить'),
),
], ],
), ),
); );
@@ -104,7 +116,8 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
IconButton( IconButton(
tooltip: 'Обновить', tooltip: 'Обновить',
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(importPreviewProvider(widget.importId)), onPressed: () =>
ref.invalidate(importPreviewProvider(widget.importId)),
), ),
], ],
), ),
@@ -131,7 +144,8 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
icon: Icons.copy_all_outlined, icon: Icons.copy_all_outlined,
color: theme.colorScheme.secondary, color: theme.colorScheme.secondary,
title: 'Этот файл уже загружали', title: 'Этот файл уже загружали',
body: 'Показан ранее созданный импорт №${p.duplicateOfId}. ' body:
'Показан ранее созданный импорт №${p.duplicateOfId}. '
'Повторная загрузка не создаёт новых событий.', 'Повторная загрузка не создаёт новых событий.',
), ),
], ],
@@ -172,8 +186,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
children: [ children: [
Text('Строки отчёта', style: theme.textTheme.titleMedium), Text('Строки отчёта', style: theme.textTheme.titleMedium),
const SizedBox(height: 4), const SizedBox(height: 4),
Text('Первые ${p.sampleEvents.length} из ${p.counts.lines}', Text(
style: theme.textTheme.bodySmall), 'Первые ${p.sampleEvents.length} из ${p.counts.lines}',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8), const SizedBox(height: 8),
SampleEventsTable(events: p.sampleEvents), SampleEventsTable(events: p.sampleEvents),
], ],
@@ -217,13 +233,17 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
_kv( _kv(
'Счёт', 'Счёт',
p.accountName ?? p.accountName ??
(p.accountId != null ? '#${p.accountId}' : 'не определён по отчёту'), (p.accountId != null
? '#${p.accountId}'
: 'не определён по отчёту'),
), ),
if (p.accountExternalId != null) _kv('Счёт в отчёте', p.accountExternalId!), if (p.accountExternalId != null)
_kv('Счёт в отчёте', p.accountExternalId!),
if (p.parserName != null) if (p.parserName != null)
_kv('Парсер', '${p.parserName} v${p.parserVersion ?? '1'}'), _kv('Парсер', '${p.parserName} v${p.parserVersion ?? '1'}'),
if (p.sizeBytes != null) _kv('Размер', formatBytes(p.sizeBytes)), if (p.sizeBytes != null) _kv('Размер', formatBytes(p.sizeBytes)),
if (p.uploadedAt != null) _kv('Загружен', ruDate(p.uploadedAt!.toLocal())), if (p.uploadedAt != null)
_kv('Загружен', ruDate(p.uploadedAt!.toLocal())),
if (p.committedAt != null) if (p.committedAt != null)
_kv('Импортирован', ruDate(p.committedAt!.toLocal())), _kv('Импортирован', ruDate(p.committedAt!.toLocal())),
], ],
@@ -232,6 +252,22 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
); );
} }
Future<void> _createAccountFromReport(ImportPreview p) async {
final created = await showAccountCreateDialog(
context,
broker: brokerFromImportKey(p.broker),
sourceId: p.accountExternalId,
name: p.accountExternalId == null
? null
: '${brokerLabel(p.broker)} ${p.accountExternalId}',
);
if (created == null || !mounted) return;
// refetch first: the dropdown only accepts a value that is among the server's suggestions
ref.invalidate(importPreviewProvider(widget.importId));
await ref.read(importPreviewProvider(widget.importId).future);
if (mounted) setState(() => _accountChoice = created.id);
}
Widget _accountPicker(ImportPreview p) { Widget _accountPicker(ImportPreview p) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Card( return Card(
@@ -243,12 +279,18 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.account_balance_outlined, color: theme.colorScheme.onErrorContainer), Icon(
Icons.account_balance_outlined,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text('Счёт не определён', child: Text(
style: theme.textTheme.titleMedium 'Счёт не определён',
?.copyWith(color: theme.colorScheme.onErrorContainer)), style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
), ),
], ],
), ),
@@ -256,10 +298,17 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
Text( Text(
p.accountSuggestions.isEmpty p.accountSuggestions.isEmpty
? 'В отчёте номер счёта ${p.accountExternalId ?? ''}, но подходящего ' ? 'В отчёте номер счёта ${p.accountExternalId ?? ''}, но подходящего '
'счёта в базе нет. Заведите счёт, затем вернитесь сюда.' 'счёта в базе нет. Создайте счёт по данным из отчёта.'
: 'Выберите счёт, в который писать события. Без него импорт недоступен.', : 'Выберите счёт, в который писать события, или создайте новый. '
'Без счёта импорт недоступен.',
style: TextStyle(color: theme.colorScheme.onErrorContainer), style: TextStyle(color: theme.colorScheme.onErrorContainer),
), ),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => _createAccountFromReport(p),
icon: const Icon(Icons.add),
label: const Text('Создать счёт из отчёта'),
),
if (p.accountSuggestions.isNotEmpty) ...[ if (p.accountSuggestions.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
DropdownButtonFormField<int>( DropdownButtonFormField<int>(
@@ -274,11 +323,13 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
for (final s in p.accountSuggestions) for (final s in p.accountSuggestions)
DropdownMenuItem( DropdownMenuItem(
value: s.id, value: s.id,
child: Text([ child: Text(
[
s.name, s.name,
if (s.broker != null) brokerLabel(s.broker), if (s.broker != null) brokerLabel(s.broker),
if (s.sourceId != null) s.sourceId!, if (s.sourceId != null) s.sourceId!,
].join(' · ')), ].join(' · '),
),
), ),
], ],
onChanged: (v) => setState(() => _accountChoice = v), onChanged: (v) => setState(() => _accountChoice = v),
@@ -308,14 +359,25 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
_stat('Строк', c.lines), _stat('Строк', c.lines),
_stat('Событий', c.eventsTotal), _stat('Событий', c.eventsTotal),
_stat('Новых', c.eventsNew, color: Colors.green), _stat('Новых', c.eventsNew, color: Colors.green),
_stat('Дубликатов', c.eventsDuplicate, _stat(
color: c.eventsDuplicate > 0 ? theme.colorScheme.secondary : null, 'Дубликатов',
hint: 'Уже есть в леджере: будут обновлены, а не продублированы'), c.eventsDuplicate,
_stat('Shadow', c.eventsShadow, color: c.eventsDuplicate > 0
hint: 'Не первичный источник — в аналитику не идут'), ? theme.colorScheme.secondary
_stat('Ждут инструмента', c.eventsPending, : null,
hint: 'Уже есть в леджере: будут обновлены, а не продублированы',
),
_stat(
'Shadow',
c.eventsShadow,
hint: 'Не первичный источник — в аналитику не идут',
),
_stat(
'Ждут инструмента',
c.eventsPending,
color: c.eventsPending > 0 ? theme.colorScheme.error : null, color: c.eventsPending > 0 ? theme.colorScheme.error : null,
hint: 'Инструмент не распознан, события останутся в статусе pending'), hint: 'Инструмент не распознан, события останутся в статусе pending',
),
], ],
), ),
if (c.byKind.isNotEmpty) ...[ if (c.byKind.isNotEmpty) ...[
@@ -329,7 +391,9 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
for (final e in c.byKind.entries) for (final e in c.byKind.entries)
Chip( Chip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
label: Text('${eventKindLabels[e.key] ?? e.key}: ${e.value}'), label: Text(
'${eventKindLabels[e.key] ?? e.key}: ${e.value}',
),
), ),
], ],
), ),
@@ -348,8 +412,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Нераспознанные инструменты (${p.pendingInstruments.length})', Text(
style: theme.textTheme.titleMedium), 'Нераспознанные инструменты (${p.pendingInstruments.length})',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Сервер не угадывает инструмент. Пока вы не привяжете эти строки вручную, ' 'Сервер не угадывает инструмент. Пока вы не привяжете эти строки вручную, '
@@ -362,11 +428,14 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
dense: true, dense: true,
title: Text(pi.title), title: Text(pi.title),
subtitle: Text([ subtitle: Text(
[
if (pi.isin != null) 'ISIN ${pi.isin}', if (pi.isin != null) 'ISIN ${pi.isin}',
'встречается ${pi.occurrences}', 'встречается ${pi.occurrences}',
if (pi.sampleQuantity != null) 'кол-во ${formatQty(pi.sampleQuantity!)}', if (pi.sampleQuantity != null)
].join(' · ')), 'кол-во ${formatQty(pi.sampleQuantity!)}',
].join(' · '),
),
), ),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
@@ -392,7 +461,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.warning_amber_outlined, color: theme.colorScheme.tertiary), Icon(
Icons.warning_amber_outlined,
color: theme.colorScheme.tertiary,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('Предупреждения', style: theme.textTheme.titleMedium), Text('Предупреждения', style: theme.textTheme.titleMedium),
], ],
@@ -434,7 +506,8 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
} }
Widget _actions(ImportPreview p, int? accountId) { Widget _actions(ImportPreview p, int? accountId) {
final canCommit = !p.isCommitted && !p.isFailed && accountId != null && !_busy; final canCommit =
!p.isCommitted && !p.isFailed && accountId != null && !_busy;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -444,8 +517,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
value: _confirmDuplicates, value: _confirmDuplicates,
onChanged: (v) => setState(() => _confirmDuplicates = v ?? false), onChanged: (v) => setState(() => _confirmDuplicates = v ?? false),
title: const Text('Обновлять дубликаты'), title: const Text('Обновлять дубликаты'),
subtitle: Text('Перезаписать ${p.counts.eventsDuplicate} событий, ' subtitle: Text(
'которые уже есть в леджере'), 'Перезаписать ${p.counts.eventsDuplicate} событий, '
'которые уже есть в леджере',
),
), ),
Wrap( Wrap(
spacing: 12, spacing: 12,
@@ -455,7 +530,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
onPressed: canCommit ? () => _commit(p) : null, onPressed: canCommit ? () => _commit(p) : null,
icon: _busy icon: _busy
? const SizedBox( ? const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.playlist_add_check), : const Icon(Icons.playlist_add_check),
label: Text(p.isCommitted ? 'Уже импортирован' : 'Импортировать'), label: Text(p.isCommitted ? 'Уже импортирован' : 'Импортировать'),
), ),
@@ -497,11 +575,11 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, Text(
style: Theme.of(context) title,
.textTheme style: Theme.of(context).textTheme.titleSmall
.titleSmall ?.copyWith(color: color),
?.copyWith(color: color)), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(body), Text(body),
], ],
@@ -525,8 +603,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: theme.textTheme.bodySmall), Text(label, style: theme.textTheme.bodySmall),
Text('$value', Text(
style: theme.textTheme.titleLarge?.copyWith(color: color)), '$value',
style: theme.textTheme.titleLarge?.copyWith(color: color),
),
], ],
), ),
); );
+33 -12
View File
@@ -35,7 +35,9 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
if (!mounted) return; if (!mounted) return;
ref.invalidate(importsListProvider); ref.invalidate(importsListProvider);
if (preview.duplicateOfId != null) { if (preview.duplicateOfId != null) {
_snack('Этот файл уже загружали — открыт существующий импорт №${preview.id}'); _snack(
'Этот файл уже загружали — открыт существующий импорт №${preview.id}',
);
} }
context.go('/imports/${preview.id}'); context.go('/imports/${preview.id}');
} catch (e) { } catch (e) {
@@ -47,7 +49,8 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
} }
void _snack(String message) => void _snack(String message) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -72,7 +75,10 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
onPressed: _uploading ? null : _upload, onPressed: _uploading ? null : _upload,
icon: _uploading icon: _uploading
? const SizedBox( ? const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file), : const Icon(Icons.upload_file),
label: Text(_uploading ? 'Загрузка…' : 'Загрузить отчёт'), label: Text(_uploading ? 'Загрузка…' : 'Загрузить отчёт'),
), ),
@@ -91,7 +97,8 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
leading: const Icon(Icons.help_outline), leading: const Icon(Icons.help_outline),
title: Text('Нераспознанных инструментов: $pendingCount'), title: Text('Нераспознанных инструментов: $pendingCount'),
subtitle: const Text( subtitle: const Text(
'События по ним ждут в статусе pending и не попадают в аналитику.'), 'События по ним ждут в статусе pending и не попадают в аналитику.',
),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => context.go('/instruments/pending'), onTap: () => context.go('/instruments/pending'),
), ),
@@ -106,11 +113,16 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
padding: EdgeInsets.only(top: 48), padding: EdgeInsets.only(top: 48),
child: EmptyState( child: EmptyState(
icon: Icons.upload_file_outlined, icon: Icons.upload_file_outlined,
message: 'Отчёты ещё не загружались.\n' message:
'Отчёты ещё не загружались.\n'
'Поддерживаются выгрузки Сбера и ВТБ (HTML, XLSX) и CSV.', 'Поддерживаются выгрузки Сбера и ВТБ (HTML, XLSX) и CSV.',
), ),
) )
: Column(children: [for (final row in rows) _ImportCard(item: row)]), : Column(
children: [
for (final row in rows) _ImportCard(item: row),
],
),
), ),
], ],
), ),
@@ -164,8 +176,12 @@ class _ImportCard extends StatelessWidget {
: 'период не определён'; : 'период не определён';
final subtitle = [ final subtitle = [
period, period,
item.accountName ?? (item.accountId != null ? 'счёт #${item.accountId}' : 'счёт не найден'), item.accountName ??
if (item.uploadedAt != null) 'загружен ${ruDate(item.uploadedAt!.toLocal())}', (item.accountId != null
? 'счёт #${item.accountId}'
: 'счёт не найден'),
if (item.uploadedAt != null)
'загружен ${ruDate(item.uploadedAt!.toLocal())}',
if (item.sizeBytes != null) formatBytes(item.sizeBytes), if (item.sizeBytes != null) formatBytes(item.sizeBytes),
].join(' · '); ].join(' · ');
@@ -174,7 +190,9 @@ class _ImportCard extends StatelessWidget {
onTap: () => context.go('/imports/${item.id}'), onTap: () => context.go('/imports/${item.id}'),
title: Row( title: Row(
children: [ children: [
Flexible(child: Text(item.filename, overflow: TextOverflow.ellipsis)), Flexible(
child: Text(item.filename, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8), const SizedBox(width: 8),
parseStatusChip(context, item.parseStatus), parseStatusChip(context, item.parseStatus),
], ],
@@ -198,9 +216,12 @@ class _ImportCard extends StatelessWidget {
if (item.isFailed && item.error != null) if (item.isFailed && item.error != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text(item.error!, child: Text(
style: theme.textTheme.bodySmall item.error!,
?.copyWith(color: theme.colorScheme.error)), style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
), ),
], ],
), ),
+8 -4
View File
@@ -6,13 +6,17 @@ import '../home/providers.dart';
import '../portfolio/providers.dart'; import '../portfolio/providers.dart';
import 'data/imports_api.dart'; import 'data/imports_api.dart';
final importsApiProvider = Provider<ImportsApi>((ref) => ImportsApi(ref.watch(apiProvider).dio)); final importsApiProvider = Provider<ImportsApi>(
(ref) => ImportsApi(ref.watch(apiProvider).dio),
);
/// The list on `/imports`. Not auto-disposed by status: the filter is a separate provider so /// The list on `/imports`. Not auto-disposed by status: the filter is a separate provider so
/// changing it refetches without rebuilding the page state. /// changing it refetches without rebuilding the page state.
final importsStatusFilterProvider = StateProvider<String?>((ref) => null); final importsStatusFilterProvider = StateProvider<String?>((ref) => null);
final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((ref) async { final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((
ref,
) async {
final status = ref.watch(importsStatusFilterProvider); final status = ref.watch(importsStatusFilterProvider);
return ref.watch(importsApiProvider).list(status: status); return ref.watch(importsApiProvider).list(status: status);
}); });
@@ -20,8 +24,8 @@ final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((ref
/// A single import's preview. For an uncommitted import the server recomputes counts and /// A single import's preview. For an uncommitted import the server recomputes counts and
/// reconciliation on every read, so this is deliberately re-fetched rather than cached from /// reconciliation on every read, so this is deliberately re-fetched rather than cached from
/// the list response. /// the list response.
final importPreviewProvider = final importPreviewProvider = FutureProvider.autoDispose
FutureProvider.autoDispose.family<ImportPreview, int>((ref, id) async { .family<ImportPreview, int>((ref, id) async {
return ref.watch(importsApiProvider).get(id); return ref.watch(importsApiProvider).get(id);
}); });
@@ -23,7 +23,10 @@ class ReconciliationCard extends StatelessWidget {
if (reconciliation.isEmpty) { if (reconciliation.isEmpty) {
return Card( return Card(
child: ListTile( child: ListTile(
leading: Icon(Icons.remove_circle_outline, color: theme.colorScheme.outline), leading: Icon(
Icons.remove_circle_outline,
color: theme.colorScheme.outline,
),
title: Text(title), title: Text(title),
subtitle: const Text('В отчёте нет остатков для сверки'), subtitle: const Text('В отчёте нет остатков для сверки'),
), ),
@@ -67,7 +70,12 @@ class ReconciliationCard extends StatelessWidget {
Text('Позиции', style: theme.textTheme.titleSmall), Text('Позиции', style: theme.textTheme.titleSmall),
const SizedBox(height: 4), const SizedBox(height: 4),
_ScrollableTable( _ScrollableTable(
columns: const ['Позиция', 'Из отчёта', 'Из леджера', 'Расхождение'], columns: const [
'Позиция',
'Из отчёта',
'Из леджера',
'Расхождение',
],
rows: [ rows: [
for (final p in reconciliation.positions) for (final p in reconciliation.positions)
_Row( _Row(
@@ -87,7 +95,12 @@ class ReconciliationCard extends StatelessWidget {
Text('Денежные остатки', style: theme.textTheme.titleSmall), Text('Денежные остатки', style: theme.textTheme.titleSmall),
const SizedBox(height: 4), const SizedBox(height: 4),
_ScrollableTable( _ScrollableTable(
columns: const ['Валюта', 'Из отчёта', 'Из леджера', 'Расхождение'], columns: const [
'Валюта',
'Из отчёта',
'Из леджера',
'Расхождение',
],
rows: [ rows: [
for (final c in reconciliation.cash) for (final c in reconciliation.cash)
_Row( _Row(
@@ -145,16 +158,20 @@ class _ScrollableTable extends StatelessWidget {
for (final r in rows) for (final r in rows)
DataRow( DataRow(
color: r.highlight color: r.highlight
? WidgetStatePropertyAll(theme.colorScheme.errorContainer.withValues(alpha: 0.4)) ? WidgetStatePropertyAll(
theme.colorScheme.errorContainer.withValues(alpha: 0.4),
)
: null, : null,
cells: [ cells: [
for (final cell in r.cells) for (final cell in r.cells)
DataCell(Text( DataCell(
Text(
cell, cell,
style: r.highlight style: r.highlight
? TextStyle(color: theme.colorScheme.error) ? TextStyle(color: theme.colorScheme.error)
: null, : null,
)), ),
),
], ],
), ),
], ],
@@ -37,11 +37,16 @@ class SampleEventsTable extends StatelessWidget {
DataRow( DataRow(
color: e.isDuplicate color: e.isDuplicate
? WidgetStatePropertyAll( ? WidgetStatePropertyAll(
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6)) theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.6,
),
)
: null, : null,
cells: [ cells: [
DataCell(Text('${e.lineNo}')), DataCell(Text('${e.lineNo}')),
DataCell(Text(e.tradeDate == null ? '' : ruDate(e.tradeDate!))), DataCell(
Text(e.tradeDate == null ? '' : ruDate(e.tradeDate!)),
),
DataCell(Text(eventKindLabels[e.kind] ?? e.kind)), DataCell(Text(eventKindLabels[e.kind] ?? e.kind)),
DataCell( DataCell(
Tooltip( Tooltip(
@@ -49,20 +54,26 @@ class SampleEventsTable extends StatelessWidget {
child: Text(e.instrumentName ?? e.instrumentKey ?? ''), child: Text(e.instrumentName ?? e.instrumentKey ?? ''),
), ),
), ),
DataCell(Text(e.quantity == null ? '' : formatQty(e.quantity!))), DataCell(
Text(e.quantity == null ? '' : formatQty(e.quantity!)),
),
DataCell(Text(_money(e.price, e.currency))), DataCell(Text(_money(e.price, e.currency))),
DataCell(Text(_money(e.amount, e.currency))), DataCell(Text(_money(e.amount, e.currency))),
DataCell(e.isDuplicate DataCell(
e.isDuplicate
? Tooltip( ? Tooltip(
message: 'Такое событие уже есть в леджере', message: 'Такое событие уже есть в леджере',
child: Chip( child: Chip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
label: const Text('дубль'), label: const Text('дубль'),
backgroundColor: backgroundColor: theme
theme.colorScheme.secondaryContainer.withValues(alpha: 0.8), .colorScheme
.secondaryContainer
.withValues(alpha: 0.8),
), ),
) )
: const SizedBox.shrink()), : const SizedBox.shrink(),
),
], ],
), ),
], ],
@@ -71,5 +82,7 @@ class SampleEventsTable extends StatelessWidget {
} }
static String _money(String? value, String? currency) => static String _money(String? value, String? currency) =>
value == null || value.isEmpty ? '' : MoneyText.format(value, currency ?? 'RUB'); value == null || value.isEmpty
? ''
: MoneyText.format(value, currency ?? 'RUB');
} }
+28 -8
View File
@@ -43,7 +43,8 @@ class IncomeCalendarTab extends ConsumerWidget {
padding: EdgeInsets.only(top: 48), padding: EdgeInsets.only(top: 48),
child: EmptyState( child: EmptyState(
icon: Icons.event_available_outlined, icon: Icons.event_available_outlined,
message: 'Ожидаемых выплат в этом окне нет.\n' message:
'Ожидаемых выплат в этом окне нет.\n'
'Либо по бумагам нет объявленных выплат и истории, либо портфель пуст.', 'Либо по бумагам нет объявленных выплат и истории, либо портфель пуст.',
), ),
) )
@@ -77,13 +78,15 @@ class _CalendarControls extends ConsumerWidget {
ChoiceChip( ChoiceChip(
label: Text('$m мес'), label: Text('$m мес'),
selected: months == m, selected: months == m,
onSelected: (_) => ref.read(calendarMonthsProvider.notifier).state = m, onSelected: (_) =>
ref.read(calendarMonthsProvider.notifier).state = m,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FilterChip( FilterChip(
label: const Text('Показать выплаченные'), label: const Text('Показать выплаченные'),
selected: includePaid, selected: includePaid,
onSelected: (v) => ref.read(calendarIncludePaidProvider.notifier).state = v, onSelected: (v) =>
ref.read(calendarIncludePaidProvider.notifier).state = v,
), ),
], ],
); );
@@ -130,7 +133,11 @@ class _Totals extends StatelessWidget {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(Icons.info_outline, size: 16, color: basisColor('history')), Icon(
Icons.info_outline,
size: 16,
color: basisColor('history'),
),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(
@@ -164,7 +171,11 @@ class _BasisTotal extends StatelessWidget {
children: [ children: [
BasisChip(basis: basis), BasisChip(basis: basis),
const SizedBox(height: 4), const SizedBox(height: 4),
MoneyText(amount, currency: 'RUB', style: Theme.of(context).textTheme.titleSmall), MoneyText(
amount,
currency: 'RUB',
style: Theme.of(context).textTheme.titleSmall,
),
], ],
), ),
); );
@@ -240,7 +251,11 @@ class _EntryRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
if (entry.amount != null) if (entry.amount != null)
MoneyText(entry.amount!, currency: entry.currency, style: theme.textTheme.titleSmall), MoneyText(
entry.amount!,
currency: entry.currency,
style: theme.textTheme.titleSmall,
),
// no FX rate for the date ⇒ no rouble figure. An em dash, never 0 ₽. // no FX rate for the date ⇒ no rouble figure. An em dash, never 0 ₽.
if (entry.currency != 'RUB') if (entry.currency != 'RUB')
Text( Text(
@@ -257,7 +272,9 @@ class _EntryRow extends StatelessWidget {
/// Groups entries by month, preserving the server's order inside each month. Entries with /// Groups entries by month, preserving the server's order inside each month. Entries with
/// no date go last, under their own heading — they are still expected money. /// no date go last, under their own heading — they are still expected money.
List<MapEntry<DateTime, List<IncomeEntry>>> _groupByMonth(List<IncomeEntry> entries) { List<MapEntry<DateTime, List<IncomeEntry>>> _groupByMonth(
List<IncomeEntry> entries,
) {
final groups = <DateTime, List<IncomeEntry>>{}; final groups = <DateTime, List<IncomeEntry>>{};
for (final e in entries) { for (final e in entries) {
final d = e.expectedDate; final d = e.expectedDate;
@@ -272,5 +289,8 @@ List<MapEntry<DateTime, List<IncomeEntry>>> _groupByMonth(List<IncomeEntry> entr
List<String> _orderedBases(Iterable<String> bases) { List<String> _orderedBases(Iterable<String> bases) {
const order = ['schedule', 'announced', 'history', 'paid']; const order = ['schedule', 'announced', 'history', 'paid'];
final set = bases.toSet(); final set = bases.toSet();
return [...order.where(set.contains), ...set.where((b) => !order.contains(b))]; return [
...order.where(set.contains),
...set.where((b) => !order.contains(b)),
];
} }
+27 -12
View File
@@ -56,7 +56,8 @@ class IncomeEntry {
final String basis; final String basis;
final String? taxWithheld; final String? taxWithheld;
String get title => ticker ?? name ?? (instrumentId == null ? '' : '#$instrumentId'); String get title =>
ticker ?? name ?? (instrumentId == null ? '' : '#$instrumentId');
static IncomeEntry fromJson(Map<String, dynamic> json) => IncomeEntry( static IncomeEntry fromJson(Map<String, dynamic> json) => IncomeEntry(
instrumentId: asInt(json['instrument_id']), instrumentId: asInt(json['instrument_id']),
@@ -118,7 +119,8 @@ class IncomeHistoryRow {
final String? taxWithheld; final String? taxWithheld;
final int paymentCount; final int paymentCount;
static IncomeHistoryRow fromJson(Map<String, dynamic> json) => IncomeHistoryRow( static IncomeHistoryRow fromJson(Map<String, dynamic> json) =>
IncomeHistoryRow(
month: asDate(json['month']), month: asDate(json['month']),
kind: asString(json['kind']) ?? 'other', kind: asString(json['kind']) ?? 'other',
currency: asString(json['currency']) ?? 'RUB', currency: asString(json['currency']) ?? 'RUB',
@@ -151,7 +153,11 @@ class IncomeHistory {
} }
class ForecastMonth { class ForecastMonth {
const ForecastMonth({required this.amountRub, this.month, this.byBasis = const {}}); const ForecastMonth({
required this.amountRub,
this.month,
this.byBasis = const {},
});
final DateTime? month; final DateTime? month;
final String amountRub; final String amountRub;
@@ -214,12 +220,15 @@ class IncomeApi {
DateTime? dateTo, DateTime? dateTo,
bool includePaid = false, bool includePaid = false,
}) async { }) async {
final r = await _dio.get<Map<String, dynamic>>('$_base/calendar', queryParameters: { final r = await _dio.get<Map<String, dynamic>>(
'$_base/calendar',
queryParameters: {
'scope': scope, 'scope': scope,
'date_from': ?_isoDate(dateFrom), 'date_from': ?_isoDate(dateFrom),
'date_to': ?_isoDate(dateTo), 'date_to': ?_isoDate(dateTo),
'include_paid': includePaid, 'include_paid': includePaid,
}); },
);
return Cached( return Cached(
IncomeCalendar.fromJson(r.data ?? const {}), IncomeCalendar.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?, fetchedAt: r.extra['fetchedAt'] as DateTime?,
@@ -233,24 +242,30 @@ class IncomeApi {
DateTime? dateTo, DateTime? dateTo,
String? kind, String? kind,
}) async { }) async {
final r = await _dio.get<Map<String, dynamic>>('$_base/history', queryParameters: { final r = await _dio.get<Map<String, dynamic>>(
'$_base/history',
queryParameters: {
'scope': scope, 'scope': scope,
'group': group, 'group': group,
'date_from': ?_isoDate(dateFrom), 'date_from': ?_isoDate(dateFrom),
'date_to': ?_isoDate(dateTo), 'date_to': ?_isoDate(dateTo),
'kind': ?kind, 'kind': ?kind,
}); },
);
return Cached( return Cached(
IncomeHistory.fromJson(r.data ?? const {}), IncomeHistory.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?, fetchedAt: r.extra['fetchedAt'] as DateTime?,
); );
} }
Future<Cached<IncomeForecast>> forecast({String scope = 'all', int months = 12}) async { Future<Cached<IncomeForecast>> forecast({
final r = await _dio.get<Map<String, dynamic>>('$_base/forecast', queryParameters: { String scope = 'all',
'scope': scope, int months = 12,
'months': months, }) async {
}); final r = await _dio.get<Map<String, dynamic>>(
'$_base/forecast',
queryParameters: {'scope': scope, 'months': months},
);
return Cached( return Cached(
IncomeForecast.fromJson(r.data ?? const {}), IncomeForecast.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?, fetchedAt: r.extra['fetchedAt'] as DateTime?,
+38 -13
View File
@@ -50,7 +50,9 @@ class IncomeForecastTab extends ConsumerWidget {
StatTile( StatTile(
label: 'Доходность к стоимости', label: 'Доходность к стоимости',
// null means "нет оценки стоимости" and must not read as 0 % // null means "нет оценки стоимости" and must not read as 0 %
value: Text(formatPercent(data.annualYieldOnValue, signed: false)), value: Text(
formatPercent(data.annualYieldOnValue, signed: false),
),
note: data.annualYieldOnValue == null note: data.annualYieldOnValue == null
? 'нет оценки текущей стоимости' ? 'нет оценки текущей стоимости'
: 'ожидаемый доход / стоимость портфеля', : 'ожидаемый доход / стоимость портфеля',
@@ -58,7 +60,10 @@ class IncomeForecastTab extends ConsumerWidget {
for (final b in bases) for (final b in bases)
StatTile( StatTile(
label: 'Основание: ${basisLabel(b)}', label: 'Основание: ${basisLabel(b)}',
value: MoneyText(_basisTotal(data, b).toString(), currency: 'RUB'), value: MoneyText(
_basisTotal(data, b).toString(),
currency: 'RUB',
),
note: basisDescription(b), note: basisDescription(b),
width: 220, width: 220,
), ),
@@ -91,8 +96,14 @@ class IncomeForecastTab extends ConsumerWidget {
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: SizedBox( child: SizedBox(
width: (data.months.length * 52).toDouble().clamp(320, double.infinity), width: (data.months.length * 52).toDouble().clamp(
child: _ForecastChart(months: data.months, bases: bases), 320,
double.infinity,
),
child: _ForecastChart(
months: data.months,
bases: bases,
),
), ),
), ),
), ),
@@ -130,7 +141,8 @@ class _HorizonChips extends ConsumerWidget {
ChoiceChip( ChoiceChip(
label: Text('$m мес'), label: Text('$m мес'),
selected: months == m, selected: months == m,
onSelected: (_) => ref.read(forecastMonthsProvider.notifier).state = m, onSelected: (_) =>
ref.read(forecastMonthsProvider.notifier).state = m,
), ),
], ],
); );
@@ -191,10 +203,16 @@ class _BasisLegend extends StatelessWidget {
Container( Container(
width: 10, width: 10,
height: 10, height: 10,
decoration: BoxDecoration(color: basisColor(b), shape: BoxShape.circle), decoration: BoxDecoration(
color: basisColor(b),
shape: BoxShape.circle,
),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(basisLabel(b), style: Theme.of(context).textTheme.bodySmall), Text(
basisLabel(b),
style: Theme.of(context).textTheme.bodySmall,
),
], ],
), ),
), ),
@@ -235,7 +253,8 @@ class _ForecastChart extends StatelessWidget {
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
final i = value.round(); final i = value.round();
if (i < 0 || i >= months.length) return const SizedBox.shrink(); if (i < 0 || i >= months.length) return const SizedBox.shrink();
if (months.length > 14 && i.isOdd) return const SizedBox.shrink(); if (months.length > 14 && i.isOdd)
return const SizedBox.shrink();
final m = months[i].month; final m = months[i].month;
return Text( return Text(
m == null ? '' : ruMonthYearShort(m), m == null ? '' : ruMonthYearShort(m),
@@ -251,7 +270,8 @@ class _ForecastChart extends StatelessWidget {
final m = months[group.x]; final m = months[group.x];
final parts = [ final parts = [
for (final b in bases) for (final b in bases)
if ((Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero) > Decimal.zero) if ((Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero) >
Decimal.zero)
'${basisLabel(b)}: ${MoneyText.format(m.byBasis[b]!, 'RUB')}', '${basisLabel(b)}: ${MoneyText.format(m.byBasis[b]!, 'RUB')}',
]; ];
return BarTooltipItem( return BarTooltipItem(
@@ -277,7 +297,8 @@ class _ForecastChart extends StatelessWidget {
final stack = <BarChartRodStackItem>[]; final stack = <BarChartRodStackItem>[];
var from = 0.0; var from = 0.0;
for (final b in bases) { for (final b in bases) {
final v = (Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero).toDouble(); final v = (Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero)
.toDouble();
if (v <= 0) continue; if (v <= 0) continue;
stack.add(BarChartRodStackItem(from, from + v, basisColor(b))); stack.add(BarChartRodStackItem(from, from + v, basisColor(b)));
from += v; from += v;
@@ -321,14 +342,18 @@ class _ForecastTable extends StatelessWidget {
for (final m in months) for (final m in months)
DataRow( DataRow(
cells: [ cells: [
DataCell(Text(m.month == null ? '' : ruMonthYearShort(m.month!))), DataCell(
Text(m.month == null ? '' : ruMonthYearShort(m.month!)),
),
for (final b in bases) for (final b in bases)
DataCell(MoneyText(m.byBasis[b] ?? '0', currency: 'RUB')), DataCell(MoneyText(m.byBasis[b] ?? '0', currency: 'RUB')),
DataCell(MoneyText( DataCell(
MoneyText(
m.amountRub, m.amountRub,
currency: 'RUB', currency: 'RUB',
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
)), ),
),
], ],
), ),
], ],
+33 -12
View File
@@ -77,7 +77,10 @@ class IncomeHistoryTab extends ConsumerWidget {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
reverse: true, reverse: true,
child: SizedBox( child: SizedBox(
width: (months.length * 44).toDouble().clamp(320, double.infinity), width: (months.length * 44).toDouble().clamp(
320,
double.infinity,
),
child: _HistoryChart(months: months), child: _HistoryChart(months: months),
), ),
), ),
@@ -109,7 +112,8 @@ class _PeriodChips extends ConsumerWidget {
ChoiceChip( ChoiceChip(
label: Text(m >= 120 ? 'Всё время' : '$m мес'), label: Text(m >= 120 ? 'Всё время' : '$m мес'),
selected: months == m, selected: months == m,
onSelected: (_) => ref.read(historyMonthsProvider.notifier).state = m, onSelected: (_) =>
ref.read(historyMonthsProvider.notifier).state = m,
), ),
], ],
); );
@@ -130,7 +134,9 @@ List<_MonthTotal> _byMonth(List<IncomeHistoryRow> rows) {
final m = r.month; final m = r.month;
if (m == null) continue; if (m == null) continue;
final key = DateTime.utc(m.year, m.month); final key = DateTime.utc(m.year, m.month);
sums[key] = (sums[key] ?? Decimal.zero) + (Decimal.tryParse(r.amountRub ?? '') ?? Decimal.zero); sums[key] =
(sums[key] ?? Decimal.zero) +
(Decimal.tryParse(r.amountRub ?? '') ?? Decimal.zero);
} }
final keys = sums.keys.toList()..sort(); final keys = sums.keys.toList()..sort();
return [for (final k in keys) _MonthTotal(k, sums[k]!)]; return [for (final k in keys) _MonthTotal(k, sums[k]!)];
@@ -166,8 +172,12 @@ class _HistoryChart extends StatelessWidget {
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
final i = value.round(); final i = value.round();
if (i < 0 || i >= months.length) return const SizedBox.shrink(); if (i < 0 || i >= months.length) return const SizedBox.shrink();
if (months.length > 14 && i % 3 != 0) return const SizedBox.shrink(); if (months.length > 14 && i % 3 != 0)
return Text(ruMonthYearShort(months[i].month), style: theme.textTheme.bodySmall); return const SizedBox.shrink();
return Text(
ruMonthYearShort(months[i].month),
style: theme.textTheme.bodySmall,
);
}, },
), ),
), ),
@@ -190,7 +200,9 @@ class _HistoryChart extends StatelessWidget {
toY: months[i].amountRub.toDouble(), toY: months[i].amountRub.toDouble(),
color: basisColor('paid'), color: basisColor('paid'),
width: 12, width: 12,
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), borderRadius: const BorderRadius.vertical(
top: Radius.circular(2),
),
), ),
], ],
), ),
@@ -216,7 +228,10 @@ class _HistoryTable extends StatelessWidget {
DataColumn(label: Text('Тип', style: headerStyle)), DataColumn(label: Text('Тип', style: headerStyle)),
DataColumn(label: Text('Валюта', style: headerStyle)), DataColumn(label: Text('Валюта', style: headerStyle)),
DataColumn(label: Text('Сумма', style: headerStyle), numeric: true), DataColumn(label: Text('Сумма', style: headerStyle), numeric: true),
DataColumn(label: Text('В рублях', style: headerStyle), numeric: true), DataColumn(
label: Text('В рублях', style: headerStyle),
numeric: true,
),
DataColumn(label: Text('Налог', style: headerStyle), numeric: true), DataColumn(label: Text('Налог', style: headerStyle), numeric: true),
DataColumn(label: Text('Выплат', style: headerStyle), numeric: true), DataColumn(label: Text('Выплат', style: headerStyle), numeric: true),
], ],
@@ -224,16 +239,22 @@ class _HistoryTable extends StatelessWidget {
for (final r in rows) for (final r in rows)
DataRow( DataRow(
cells: [ cells: [
DataCell(Text(r.month == null ? '' : ruMonthYearShort(r.month!))), DataCell(
Text(r.month == null ? '' : ruMonthYearShort(r.month!)),
),
DataCell(Text(incomeKindLabel(r.kind))), DataCell(Text(incomeKindLabel(r.kind))),
DataCell(Text(r.currency)), DataCell(Text(r.currency)),
DataCell(MoneyText(r.amount, currency: r.currency)), DataCell(MoneyText(r.amount, currency: r.currency)),
DataCell(r.amountRub == null DataCell(
r.amountRub == null
? const Text('') ? const Text('')
: MoneyText(r.amountRub!, currency: 'RUB')), : MoneyText(r.amountRub!, currency: 'RUB'),
DataCell(r.taxWithheld == null ),
DataCell(
r.taxWithheld == null
? const Text('') ? const Text('')
: MoneyText(r.taxWithheld!, currency: r.currency)), : MoneyText(r.taxWithheld!, currency: r.currency),
),
DataCell(Text('${r.paymentCount}')), DataCell(Text('${r.paymentCount}')),
], ],
), ),
+10 -2
View File
@@ -40,7 +40,11 @@ class IncomePage extends ConsumerWidget {
), ),
], ],
bottom: const TabBar( bottom: const TabBar(
tabs: [Tab(text: 'Календарь'), Tab(text: 'История'), Tab(text: 'Прогноз')], tabs: [
Tab(text: 'Календарь'),
Tab(text: 'История'),
Tab(text: 'Прогноз'),
],
), ),
), ),
body: Column( body: Column(
@@ -52,7 +56,11 @@ class IncomePage extends ConsumerWidget {
), ),
const Expanded( const Expanded(
child: TabBarView( child: TabBarView(
children: [IncomeCalendarTab(), IncomeHistoryTab(), IncomeForecastTab()], children: [
IncomeCalendarTab(),
IncomeHistoryTab(),
IncomeForecastTab(),
],
), ),
), ),
], ],
+6 -2
View File
@@ -26,7 +26,8 @@ const basisLabels = {
const basisDescriptions = { const basisDescriptions = {
'schedule': 'Арифметика по опубликованному графику выплат эмитента.', 'schedule': 'Арифметика по опубликованному графику выплат эмитента.',
'announced': 'Объявленный эмитентом факт: размер и дата известны.', 'announced': 'Объявленный эмитентом факт: размер и дата известны.',
'history': 'Экстраполяция по выплатам за последние 24 мес — может ошибаться ' 'history':
'Экстраполяция по выплатам за последние 24 мес — может ошибаться '
'на любую величину, в том числе выплаты может не быть вовсе.', 'на любую величину, в том числе выплаты может не быть вовсе.',
'paid': 'Уже получено.', 'paid': 'Уже получено.',
}; };
@@ -58,7 +59,10 @@ class BasisChip extends StatelessWidget {
return Tooltip( return Tooltip(
message: basisDescription(basis), message: basisDescription(basis),
child: Container( child: Container(
padding: EdgeInsets.symmetric(horizontal: dense ? 6 : 10, vertical: dense ? 1 : 4), padding: EdgeInsets.symmetric(
horizontal: dense ? 6 : 10,
vertical: dense ? 1 : 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.14), color: color.withValues(alpha: 0.14),
border: Border.all(color: color.withValues(alpha: 0.5)), border: Border.all(color: color.withValues(alpha: 0.5)),
+17 -7
View File
@@ -5,7 +5,9 @@ import '../../core/cache/cached.dart';
import '../portfolio/providers.dart' show scopeProvider; import '../portfolio/providers.dart' show scopeProvider;
import 'data/income_api.dart'; import 'data/income_api.dart';
final incomeApiProvider = Provider<IncomeApi>((ref) => IncomeApi(ref.watch(apiProvider).dio)); final incomeApiProvider = Provider<IncomeApi>(
(ref) => IncomeApi(ref.watch(apiProvider).dio),
);
/// How far the calendar looks ahead, in months. 12 is the contract default. /// How far the calendar looks ahead, in months. 12 is the contract default.
final calendarMonthsProvider = StateProvider<int>((ref) => 12); final calendarMonthsProvider = StateProvider<int>((ref) => 12);
@@ -14,12 +16,15 @@ final calendarIncludePaidProvider = StateProvider<bool>((ref) => false);
/// Доходы shares the portfolio-wide [scopeProvider]: switching the scope on Портфель must /// Доходы shares the portfolio-wide [scopeProvider]: switching the scope on Портфель must
/// not leave the income calendar showing a different portfolio. See `docs/ai/offline-cache.md`. /// not leave the income calendar showing a different portfolio. See `docs/ai/offline-cache.md`.
final incomeCalendarProvider = FutureProvider.autoDispose<Cached<IncomeCalendar>>((ref) async { final incomeCalendarProvider =
FutureProvider.autoDispose<Cached<IncomeCalendar>>((ref) async {
final scope = ref.watch(scopeProvider); final scope = ref.watch(scopeProvider);
final months = ref.watch(calendarMonthsProvider); final months = ref.watch(calendarMonthsProvider);
final includePaid = ref.watch(calendarIncludePaidProvider); final includePaid = ref.watch(calendarIncludePaidProvider);
final now = DateTime.now(); final now = DateTime.now();
return ref.watch(incomeApiProvider).calendar( return ref
.watch(incomeApiProvider)
.calendar(
scope: scope, scope: scope,
dateFrom: DateTime(now.year, now.month, now.day), dateFrom: DateTime(now.year, now.month, now.day),
dateTo: DateTime(now.year, now.month + months, now.day), dateTo: DateTime(now.year, now.month + months, now.day),
@@ -30,20 +35,25 @@ final incomeCalendarProvider = FutureProvider.autoDispose<Cached<IncomeCalendar>
/// How far the history goes back, in months. /// How far the history goes back, in months.
final historyMonthsProvider = StateProvider<int>((ref) => 24); final historyMonthsProvider = StateProvider<int>((ref) => 24);
final incomeHistoryProvider = FutureProvider.autoDispose<Cached<IncomeHistory>>((ref) async { final incomeHistoryProvider = FutureProvider.autoDispose<Cached<IncomeHistory>>(
(ref) async {
final scope = ref.watch(scopeProvider); final scope = ref.watch(scopeProvider);
final months = ref.watch(historyMonthsProvider); final months = ref.watch(historyMonthsProvider);
final now = DateTime.now(); final now = DateTime.now();
return ref.watch(incomeApiProvider).history( return ref
.watch(incomeApiProvider)
.history(
scope: scope, scope: scope,
dateFrom: DateTime(now.year, now.month - months + 1, 1), dateFrom: DateTime(now.year, now.month - months + 1, 1),
dateTo: DateTime(now.year, now.month + 1, 0), dateTo: DateTime(now.year, now.month + 1, 0),
); );
}); },
);
final forecastMonthsProvider = StateProvider<int>((ref) => 12); final forecastMonthsProvider = StateProvider<int>((ref) => 12);
final incomeForecastProvider = FutureProvider.autoDispose<Cached<IncomeForecast>>((ref) async { final incomeForecastProvider =
FutureProvider.autoDispose<Cached<IncomeForecast>>((ref) async {
final scope = ref.watch(scopeProvider); final scope = ref.watch(scopeProvider);
return ref return ref
.watch(incomeApiProvider) .watch(incomeApiProvider)
+20 -7
View File
@@ -60,7 +60,8 @@ class PendingInstrument {
return isin ?? sourceKey; return isin ?? sourceKey;
} }
static PendingInstrument fromJson(Map<String, dynamic> json) => PendingInstrument( static PendingInstrument fromJson(Map<String, dynamic> json) =>
PendingInstrument(
id: asInt(json['id'])!, id: asInt(json['id'])!,
source: asString(json['source']) ?? '', source: asString(json['source']) ?? '',
sourceKey: asString(json['source_key']) ?? '', sourceKey: asString(json['source_key']) ?? '',
@@ -98,7 +99,8 @@ class PendingResolveResult {
final bool aliasCreated; final bool aliasCreated;
final bool metricsRefreshed; final bool metricsRefreshed;
static PendingResolveResult fromJson(Map<String, dynamic> json) => PendingResolveResult( static PendingResolveResult fromJson(Map<String, dynamic> json) =>
PendingResolveResult(
id: asInt(json['id']) ?? 0, id: asInt(json['id']) ?? 0,
status: asString(json['status']) ?? 'resolved', status: asString(json['status']) ?? 'resolved',
instrumentId: asInt(json['instrument_id']), instrumentId: asInt(json['instrument_id']),
@@ -172,7 +174,10 @@ class PendingApi {
queryParameters: {'status': status, 'limit': limit, 'offset': offset}, queryParameters: {'status': status, 'limit': limit, 'offset': offset},
); );
return (r.data ?? const []) return (r.data ?? const [])
.map((e) => PendingInstrument.fromJson(Map<String, dynamic>.from(e as Map))) .map(
(e) =>
PendingInstrument.fromJson(Map<String, dynamic>.from(e as Map)),
)
.toList(); .toList();
} }
@@ -182,10 +187,17 @@ class PendingApi {
Future<PendingResolveResult> create(int id, NewInstrument instrument) => Future<PendingResolveResult> create(int id, NewInstrument instrument) =>
_resolve(id, {'action': 'create', 'instrument': instrument.toJson()}); _resolve(id, {'action': 'create', 'instrument': instrument.toJson()});
Future<PendingResolveResult> ignore(int id) => _resolve(id, {'action': 'ignore'}); Future<PendingResolveResult> ignore(int id) =>
_resolve(id, {'action': 'ignore'});
Future<PendingResolveResult> _resolve(int id, Map<String, dynamic> body) async { Future<PendingResolveResult> _resolve(
final r = await _dio.post<Map<String, dynamic>>('$_base/$id/resolve', data: body); int id,
Map<String, dynamic> body,
) async {
final r = await _dio.post<Map<String, dynamic>>(
'$_base/$id/resolve',
data: body,
);
return PendingResolveResult.fromJson(r.data ?? const {}); return PendingResolveResult.fromJson(r.data ?? const {});
} }
} }
@@ -196,7 +208,8 @@ class PendingApi {
// not convert to `double` anywhere — a numeric JSON value (should one ever appear) is kept // not convert to `double` anywhere — a numeric JSON value (should one ever appear) is kept
// as its lossless string form and parsed into `Decimal` at the point of display. // as its lossless string form and parsed into `Decimal` at the point of display.
String? asString(Object? v) => v == null ? null : (v is String ? v : v.toString()); String? asString(Object? v) =>
v == null ? null : (v is String ? v : v.toString());
int? asInt(Object? v) => switch (v) { int? asInt(Object? v) => switch (v) {
null => null, null => null,
+48 -18
View File
@@ -18,16 +18,22 @@ class PendingInstrumentsPage extends ConsumerStatefulWidget {
const PendingInstrumentsPage({super.key}); const PendingInstrumentsPage({super.key});
@override @override
ConsumerState<PendingInstrumentsPage> createState() => _PendingInstrumentsPageState(); ConsumerState<PendingInstrumentsPage> createState() =>
_PendingInstrumentsPageState();
} }
class _PendingInstrumentsPageState extends ConsumerState<PendingInstrumentsPage> { class _PendingInstrumentsPageState
extends ConsumerState<PendingInstrumentsPage> {
int? _busyId; int? _busyId;
void _snack(String message) => void _snack(String message) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
Future<void> _run(int id, Future<PendingResolveResult> Function() action) async { Future<void> _run(
int id,
Future<PendingResolveResult> Function() action,
) async {
setState(() => _busyId = id); setState(() => _busyId = id);
try { try {
final result = await action(); final result = await action();
@@ -36,10 +42,12 @@ class _PendingInstrumentsPageState extends ConsumerState<PendingInstrumentsPage>
ref.invalidate(pendingCountProvider); ref.invalidate(pendingCountProvider);
// Events moved out of `pending`, so holdings, allocation and the event list changed. // Events moved out of `pending`, so holdings, allocation and the event list changed.
invalidateLedgerDependents(ref); invalidateLedgerDependents(ref);
_snack(result.status == 'ignored' _snack(
result.status == 'ignored'
? 'Строка помечена как «не инструмент»' ? 'Строка помечена как «не инструмент»'
: 'Привязано событий: ${result.eventsBound}' : 'Привязано событий: ${result.eventsBound}'
'${result.aliasCreated ? ', добавлен алиас' : ''}'); '${result.aliasCreated ? ', добавлен алиас' : ''}',
);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
_snack(importErrorMessage(e)); _snack(importErrorMessage(e));
@@ -56,7 +64,10 @@ class _PendingInstrumentsPageState extends ConsumerState<PendingInstrumentsPage>
), ),
); );
if (instrumentId == null || !mounted) return; if (instrumentId == null || !mounted) return;
await _run(p.id, () => ref.read(pendingApiProvider).link(p.id, instrumentId)); await _run(
p.id,
() => ref.read(pendingApiProvider).link(p.id, instrumentId),
);
} }
Future<void> _create(PendingInstrument p) async { Future<void> _create(PendingInstrument p) async {
@@ -65,7 +76,10 @@ class _PendingInstrumentsPageState extends ConsumerState<PendingInstrumentsPage>
builder: (_) => CreateInstrumentDialog(pending: p), builder: (_) => CreateInstrumentDialog(pending: p),
); );
if (instrument == null || !mounted) return; if (instrument == null || !mounted) return;
await _run(p.id, () => ref.read(pendingApiProvider).create(p.id, instrument)); await _run(
p.id,
() => ref.read(pendingApiProvider).create(p.id, instrument),
);
} }
Future<void> _ignore(PendingInstrument p) async { Future<void> _ignore(PendingInstrument p) async {
@@ -73,14 +87,19 @@ class _PendingInstrumentsPageState extends ConsumerState<PendingInstrumentsPage>
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Игнорировать строку?'), title: const Text('Игнорировать строку?'),
content: Text('«${p.title}» больше не будет предлагаться к резолву. ' content: Text(
'Её события останутся без инструмента.'), '«${p.title}» больше не будет предлагаться к резолву. '
'Её события останутся без инструмента.',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(false), child: const Text('Отмена')), onPressed: () => Navigator.of(context).pop(false),
child: const Text('Отмена'),
),
FilledButton( FilledButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.of(context).pop(true),
child: const Text('Игнорировать')), child: const Text('Игнорировать'),
),
], ],
), ),
); );
@@ -164,7 +183,8 @@ class _StatusFilter extends ConsumerWidget {
ChoiceChip( ChoiceChip(
label: Text(e.value), label: Text(e.value),
selected: current == e.key, selected: current == e.key,
onSelected: (_) => ref.read(pendingStatusFilterProvider.notifier).state = e.key, onSelected: (_) =>
ref.read(pendingStatusFilterProvider.notifier).state = e.key,
), ),
], ],
); );
@@ -197,7 +217,8 @@ class _PendingCard extends StatelessWidget {
if (p.ticker != null) 'тикер ${p.ticker}', if (p.ticker != null) 'тикер ${p.ticker}',
if (p.board != null) 'доска ${p.board}', if (p.board != null) 'доска ${p.board}',
if (p.currency != null) p.currency!, if (p.currency != null) p.currency!,
if (p.assetClassHint != null) 'в отчёте: ${assetClassLabel(p.assetClassHint)}', if (p.assetClassHint != null)
'в отчёте: ${assetClassLabel(p.assetClassHint)}',
]; ];
final sample = <String>[ final sample = <String>[
'встречается ${p.occurrences} раз', 'встречается ${p.occurrences} раз',
@@ -214,18 +235,25 @@ class _PendingCard extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
Expanded(child: Text(p.title, style: theme.textTheme.titleMedium)), Expanded(
child: Text(p.title, style: theme.textTheme.titleMedium),
),
if (!open) if (!open)
Chip( Chip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
label: Text(p.status == 'ignored' ? 'игнорируется' : 'привязан'), label: Text(
p.status == 'ignored' ? 'игнорируется' : 'привязан',
),
), ),
], ],
), ),
if (facts.isNotEmpty) if (facts.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text(facts.join(' · '), style: theme.textTheme.bodySmall), child: Text(
facts.join(' · '),
style: theme.textTheme.bodySmall,
),
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 2), padding: const EdgeInsets.only(top: 2),
@@ -236,7 +264,9 @@ class _PendingCard extends StatelessWidget {
child: Text( child: Text(
'источник ${p.source} · ключ ${p.sourceKey}' 'источник ${p.source} · ключ ${p.sourceKey}'
'${p.firstSeenFileId != null ? ' · файл №${p.firstSeenFileId}' : ''}', '${p.firstSeenFileId != null ? ' · файл №${p.firstSeenFileId}' : ''}',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline), style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.outline,
),
), ),
), ),
if (open) ...[ if (open) ...[
+5 -3
View File
@@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart'; import '../../core/api/api_client.dart';
import 'data/pending_api.dart'; import 'data/pending_api.dart';
final pendingApiProvider = Provider<PendingApi>((ref) => PendingApi(ref.watch(apiProvider).dio)); final pendingApiProvider = Provider<PendingApi>(
(ref) => PendingApi(ref.watch(apiProvider).dio),
);
/// `pending | resolved | ignored | all` — the filter of the resolve screen. /// `pending | resolved | ignored | all` — the filter of the resolve screen.
final pendingStatusFilterProvider = StateProvider<String>((ref) => 'pending'); final pendingStatusFilterProvider = StateProvider<String>((ref) => 'pending');
@@ -23,8 +25,8 @@ final pendingCountProvider = FutureProvider.autoDispose<int>((ref) async {
/// Instrument search for the "link to an existing instrument" dialog. This one endpoint is /// Instrument search for the "link to an existing instrument" dialog. This one endpoint is
/// already in the generated client, so it goes through it rather than raw Dio. /// already in the generated client, so it goes through it rather than raw Dio.
final instrumentSearchProvider = final instrumentSearchProvider = FutureProvider.autoDispose
FutureProvider.autoDispose.family<List<InstrumentOut>, String>((ref, query) async { .family<List<InstrumentOut>, String>((ref, query) async {
if (query.trim().length < 2) return const []; if (query.trim().length < 2) return const [];
final r = await ref final r = await ref
.watch(apiProvider) .watch(apiProvider)
@@ -21,12 +21,15 @@ class _CreateInstrumentDialogState extends State<CreateInstrumentDialog> {
late final _ticker = TextEditingController(text: widget.pending.ticker ?? ''); late final _ticker = TextEditingController(text: widget.pending.ticker ?? '');
late final _board = TextEditingController(text: widget.pending.board ?? ''); late final _board = TextEditingController(text: widget.pending.board ?? '');
late final _name = TextEditingController(text: widget.pending.name ?? ''); late final _name = TextEditingController(text: widget.pending.name ?? '');
late final _currency = TextEditingController(text: widget.pending.currency ?? 'RUB'); late final _currency = TextEditingController(
text: widget.pending.currency ?? 'RUB',
);
late final _lot = TextEditingController(text: '1'); late final _lot = TextEditingController(text: '1');
/// `asset_class_hint` is what the report's own section said (e.g. the «Фонды» table), so /// `asset_class_hint` is what the report's own section said (e.g. the «Фонды» table), so
/// it is offered as the initial value of a control the user must still look at. /// it is offered as the initial value of a control the user must still look at.
late String _assetClass = assetClassKeys.contains(widget.pending.assetClassHint) late String _assetClass =
assetClassKeys.contains(widget.pending.assetClassHint)
? widget.pending.assetClassHint! ? widget.pending.assetClassHint!
: 'share'; : 'share';
@@ -43,7 +46,8 @@ class _CreateInstrumentDialogState extends State<CreateInstrumentDialog> {
void _submit() { void _submit() {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
Navigator.of(context).pop(NewInstrument( Navigator.of(context).pop(
NewInstrument(
assetClass: _assetClass, assetClass: _assetClass,
name: _name.text.trim(), name: _name.text.trim(),
currency: _currency.text.trim().toUpperCase(), currency: _currency.text.trim().toUpperCase(),
@@ -51,7 +55,8 @@ class _CreateInstrumentDialogState extends State<CreateInstrumentDialog> {
ticker: _ticker.text.trim(), ticker: _ticker.text.trim(),
board: _board.text.trim(), board: _board.text.trim(),
lot: int.tryParse(_lot.text.trim()), lot: int.tryParse(_lot.text.trim()),
)); ),
);
} }
@override @override
@@ -72,27 +77,44 @@ class _CreateInstrumentDialogState extends State<CreateInstrumentDialog> {
decoration: const InputDecoration(labelText: 'Класс актива'), decoration: const InputDecoration(labelText: 'Класс актива'),
items: [ items: [
for (final key in assetClassKeys) for (final key in assetClassKeys)
DropdownMenuItem(value: key, child: Text('${assetClassLabel(key)} ($key)')), DropdownMenuItem(
value: key,
child: Text('${assetClassLabel(key)} ($key)'),
),
], ],
onChanged: (v) => setState(() => _assetClass = v ?? _assetClass), onChanged: (v) =>
setState(() => _assetClass = v ?? _assetClass),
), ),
_field(_name, 'Название', required: true), _field(_name, 'Название', required: true),
_field(_isin, 'ISIN'), _field(_isin, 'ISIN'),
_field(_ticker, 'Тикер'), _field(_ticker, 'Тикер'),
_field(_board, 'Доска (TQBR, TQTF…)'), _field(_board, 'Доска (TQBR, TQTF…)'),
_field(_currency, 'Валюта', required: true), _field(_currency, 'Валюта', required: true),
_field(_lot, 'Лот', keyboard: TextInputType.number, validator: (v) { _field(
_lot,
'Лот',
keyboard: TextInputType.number,
validator: (v) {
if (v == null || v.trim().isEmpty) return null; if (v == null || v.trim().isEmpty) return null;
return int.tryParse(v.trim()) == null ? 'Целое число' : null; return int.tryParse(v.trim()) == null
}), ? 'Целое число'
: null;
},
),
], ],
), ),
), ),
), ),
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Отмена')), TextButton(
FilledButton(onPressed: _submit, child: const Text('Создать и привязать')), onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: _submit,
child: const Text('Создать и привязать'),
),
], ],
); );
} }
@@ -110,9 +132,12 @@ class _CreateInstrumentDialogState extends State<CreateInstrumentDialog> {
controller: controller, controller: controller,
keyboardType: keyboard, keyboardType: keyboard,
decoration: InputDecoration(labelText: label, isDense: true), decoration: InputDecoration(labelText: label, isDense: true),
validator: validator ?? validator:
validator ??
(required (required
? (v) => (v == null || v.trim().isEmpty) ? 'Обязательное поле' : null ? (v) => (v == null || v.trim().isEmpty)
? 'Обязательное поле'
: null
: null), : null),
), ),
); );
@@ -18,12 +18,14 @@ class LinkInstrumentDialog extends ConsumerStatefulWidget {
final String initialQuery; final String initialQuery;
@override @override
ConsumerState<LinkInstrumentDialog> createState() => _LinkInstrumentDialogState(); ConsumerState<LinkInstrumentDialog> createState() =>
_LinkInstrumentDialogState();
} }
class _LinkInstrumentDialogState extends ConsumerState<LinkInstrumentDialog> { class _LinkInstrumentDialogState extends ConsumerState<LinkInstrumentDialog> {
late final TextEditingController _controller = late final TextEditingController _controller = TextEditingController(
TextEditingController(text: widget.initialQuery); text: widget.initialQuery,
);
String _query = ''; String _query = '';
Timer? _debounce; Timer? _debounce;
@@ -75,7 +77,9 @@ class _LinkInstrumentDialogState extends ConsumerState<LinkInstrumentDialog> {
error: (e, _) => Center(child: Text('$e')), error: (e, _) => Center(child: Text('$e')),
data: (rows) { data: (rows) {
if (_query.trim().length < 2) { if (_query.trim().length < 2) {
return const Center(child: Text('Введите минимум 2 символа')); return const Center(
child: Text('Введите минимум 2 символа'),
);
} }
if (rows.isEmpty) { if (rows.isEmpty) {
return const Center(child: Text('Ничего не найдено')); return const Center(child: Text('Ничего не найдено'));
@@ -108,10 +112,12 @@ class _LinkInstrumentDialogState extends ConsumerState<LinkInstrumentDialog> {
].join(' · '); ].join(' · ');
return ListTile( return ListTile(
dense: true, dense: true,
title: Text([ title: Text(
[
if (instrument.ticker != null) instrument.ticker!, if (instrument.ticker != null) instrument.ticker!,
instrument.name, instrument.name,
].join(' · ')), ].join(' · '),
),
subtitle: Text(subtitle), subtitle: Text(subtitle),
onTap: () => Navigator.of(context).pop(instrument.id), onTap: () => Navigator.of(context).pop(instrument.id),
); );
@@ -34,8 +34,12 @@ class TargetWeight {
final String? band; final String? band;
final String? note; final String? note;
TargetWeight copyWith({String? bucket, String? targetWeight, String? band, String? note}) => TargetWeight copyWith({
TargetWeight( String? bucket,
String? targetWeight,
String? band,
String? note,
}) => TargetWeight(
bucket: bucket ?? this.bucket, bucket: bucket ?? this.bucket,
targetWeight: targetWeight ?? this.targetWeight, targetWeight: targetWeight ?? this.targetWeight,
band: band ?? this.band, band: band ?? this.band,
@@ -58,7 +62,11 @@ class TargetWeight {
} }
class TargetSet { class TargetSet {
const TargetSet({required this.dimension, this.targets = const [], this.weightsSum}); const TargetSet({
required this.dimension,
this.targets = const [],
this.weightsSum,
});
final String dimension; final String dimension;
final List<TargetWeight> targets; final List<TargetWeight> targets;
@@ -72,7 +80,8 @@ class TargetSet {
Decimal get localSum => sumDecimals(targets.map((t) => t.targetWeight)); Decimal get localSum => sumDecimals(targets.map((t) => t.targetWeight));
/// The contract's tolerance: the sum must be 1 within 0.0001. /// The contract's tolerance: the sum must be 1 within 0.0001.
bool get sumIsValid => (localSum - Decimal.one).abs() <= Decimal.parse('0.0001'); bool get sumIsValid =>
(localSum - Decimal.one).abs() <= Decimal.parse('0.0001');
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'dimension': dimension, 'dimension': dimension,
@@ -119,7 +128,8 @@ class RebalanceTrade {
/// tell an underweight recommendation from a wrong one. /// tell an underweight recommendation from a wrong one.
final bool blockedByCash; final bool blockedByCash;
String get title => ticker ?? name ?? (instrumentId == null ? '' : '#$instrumentId'); String get title =>
ticker ?? name ?? (instrumentId == null ? '' : '#$instrumentId');
static RebalanceTrade fromJson(Map<String, dynamic> json) => RebalanceTrade( static RebalanceTrade fromJson(Map<String, dynamic> json) => RebalanceTrade(
instrumentId: asInt(json['instrument_id']), instrumentId: asInt(json['instrument_id']),
@@ -191,7 +201,8 @@ class RebalancePlan {
final List<RebalanceBucket> buckets; final List<RebalanceBucket> buckets;
final List<String> warnings; final List<String> warnings;
bool get everythingWithinBand => buckets.isNotEmpty && buckets.every((b) => b.withinBand); bool get everythingWithinBand =>
buckets.isNotEmpty && buckets.every((b) => b.withinBand);
static RebalancePlan fromJson(Map<String, dynamic> json) => RebalancePlan( static RebalancePlan fromJson(Map<String, dynamic> json) => RebalancePlan(
portfolioId: asInt(json['portfolio_id']) ?? 0, portfolioId: asInt(json['portfolio_id']) ?? 0,
@@ -217,12 +228,18 @@ class RebalanceApi {
/// not — revisit once the route is in the spec. /// not — revisit once the route is in the spec.
/// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly /// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly
/// (there is no generated `Response<T>` for the screen to unwrap `r.cached` from). /// (there is no generated `Response<T>` for the screen to unwrap `r.cached` from).
Future<Cached<TargetSet>> getTargets(int portfolioId, {String dimension = 'asset_class'}) async { Future<Cached<TargetSet>> getTargets(
int portfolioId, {
String dimension = 'asset_class',
}) async {
final r = await _dio.get<Map<String, dynamic>>( final r = await _dio.get<Map<String, dynamic>>(
'$_base/$portfolioId/targets', '$_base/$portfolioId/targets',
queryParameters: {'dimension': dimension}, queryParameters: {'dimension': dimension},
); );
return Cached(TargetSet.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?); return Cached(
TargetSet.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
} }
/// Full replacement of one dimension — partial updates are not supported by the contract. /// Full replacement of one dimension — partial updates are not supported by the contract.
@@ -241,7 +258,10 @@ class RebalanceApi {
}) async { }) async {
final r = await _dio.get<Map<String, dynamic>>( final r = await _dio.get<Map<String, dynamic>>(
'$_base/$portfolioId/rebalance', '$_base/$portfolioId/rebalance',
queryParameters: {'dimension': dimension, 'cash_available': ?cashAvailable}, queryParameters: {
'dimension': dimension,
'cash_available': ?cashAvailable,
},
); );
return Cached( return Cached(
RebalancePlan.fromJson(r.data ?? const {}), RebalancePlan.fromJson(r.data ?? const {}),
+3 -1
View File
@@ -10,5 +10,7 @@ String bucketLabelForKey(String dimension, String bucket) {
if (bucket == 'cash') return 'Денежные средства'; if (bucket == 'cash') return 'Денежные средства';
if (bucket == 'unknown') return 'Не указано'; if (bucket == 'unknown') return 'Не указано';
if (bucket.isEmpty) return ''; if (bucket.isEmpty) return '';
return dimension == 'asset_class' ? (assetClassLabels[bucket] ?? bucket) : bucket; return dimension == 'asset_class'
? (assetClassLabels[bucket] ?? bucket)
: bucket;
} }
+29 -10
View File
@@ -62,7 +62,9 @@ class RebalancePlanTab extends ConsumerWidget {
child: const ListTile( child: const ListTile(
leading: Icon(Icons.check_circle_outline), leading: Icon(Icons.check_circle_outline),
title: Text('Все группы внутри коридора'), title: Text('Все группы внутри коридора'),
subtitle: Text('Действий не требуется — отклонения меньше заданного допуска.'), subtitle: Text(
'Действий не требуется — отклонения меньше заданного допуска.',
),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -104,7 +106,9 @@ class _Header extends ConsumerWidget {
: MoneyText(plan.totalValueRub!, currency: 'RUB'), : MoneyText(plan.totalValueRub!, currency: 'RUB'),
), ),
_Figure( _Figure(
label: whatIf == null ? 'Доступно денег' : 'Доступно денег (what-if)', label: whatIf == null
? 'Доступно денег'
: 'Доступно денег (what-if)',
child: plan.cashAvailableRub == null child: plan.cashAvailableRub == null
? const Text('') ? const Text('')
: MoneyText(plan.cashAvailableRub!, currency: 'RUB'), : MoneyText(plan.cashAvailableRub!, currency: 'RUB'),
@@ -149,8 +153,9 @@ class _WhatIfCashField extends ConsumerStatefulWidget {
} }
class _WhatIfCashFieldState extends ConsumerState<_WhatIfCashField> { class _WhatIfCashFieldState extends ConsumerState<_WhatIfCashField> {
late final TextEditingController _controller = late final TextEditingController _controller = TextEditingController(
TextEditingController(text: widget.current ?? ''); text: widget.current ?? '',
);
@override @override
void dispose() { void dispose() {
@@ -159,7 +164,10 @@ class _WhatIfCashFieldState extends ConsumerState<_WhatIfCashField> {
} }
void _apply() { void _apply() {
final text = _controller.text.trim().replaceAll(',', '.').replaceAll(' ', ''); final text = _controller.text
.trim()
.replaceAll(',', '.')
.replaceAll(' ', '');
ref.read(whatIfCashProvider.notifier).state = text.isEmpty ? null : text; ref.read(whatIfCashProvider.notifier).state = text.isEmpty ? null : text;
} }
@@ -228,7 +236,8 @@ class _BucketCard extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
return SectionCard( return SectionCard(
title: bucketLabelForKey(dimension, bucket.bucket), title: bucketLabelForKey(dimension, bucket.bucket),
subtitle: 'сейчас ${formatShareAsPercent(bucket.currentWeight)} · ' subtitle:
'сейчас ${formatShareAsPercent(bucket.currentWeight)} · '
'цель ${formatShareAsPercent(bucket.targetWeight)} · ' 'цель ${formatShareAsPercent(bucket.targetWeight)} · '
'отклонение ${formatShareAsPercent(bucket.drift, signed: true)}', 'отклонение ${formatShareAsPercent(bucket.drift, signed: true)}',
trailing: bucket.withinBand trailing: bucket.withinBand
@@ -238,7 +247,9 @@ class _BucketCard extends StatelessWidget {
: MoneyText( : MoneyText(
bucket.deltaValueRub!, bucket.deltaValueRub!,
currency: 'RUB', currency: 'RUB',
style: TextStyle(color: signColor(context, bucket.deltaValueRub)), style: TextStyle(
color: signColor(context, bucket.deltaValueRub),
),
)), )),
child: bucket.withinBand child: bucket.withinBand
? Text( ? Text(
@@ -251,7 +262,9 @@ class _BucketCard extends StatelessWidget {
'вероятно, у подходящих бумаг нет цены — см. предупреждения выше.', 'вероятно, у подходящих бумаг нет цены — см. предупреждения выше.',
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
) )
: Column(children: [for (final t in bucket.trades) TradeRow(trade: t)]), : Column(
children: [for (final t in bucket.trades) TradeRow(trade: t)],
),
); );
} }
} }
@@ -301,7 +314,9 @@ class TradeRow extends StatelessWidget {
: () => context.push('/portfolio/instrument/${trade.instrumentId}'), : () => context.push('/portfolio/instrument/${trade.instrumentId}'),
leading: Icon( leading: Icon(
isBuy ? Icons.add_circle_outline : Icons.remove_circle_outline, isBuy ? Icons.add_circle_outline : Icons.remove_circle_outline,
color: isBuy ? Theme.of(context).colorScheme.primary : theme.colorScheme.error, color: isBuy
? Theme.of(context).colorScheme.primary
: theme.colorScheme.error,
), ),
title: Row( title: Row(
children: [ children: [
@@ -320,7 +335,11 @@ class TradeRow extends StatelessWidget {
), ),
trailing: noQty || trade.amountRub == null trailing: noQty || trade.amountRub == null
? const Text('') ? const Text('')
: MoneyText(trade.amountRub!, currency: 'RUB', style: theme.textTheme.titleSmall), : MoneyText(
trade.amountRub!,
currency: 'RUB',
style: theme.textTheme.titleSmall,
),
); );
} }
} }
+14 -6
View File
@@ -5,8 +5,9 @@ import '../../core/cache/cached.dart';
import '../portfolio/providers.dart' show scopeProvider, scopesProvider; import '../portfolio/providers.dart' show scopeProvider, scopesProvider;
import 'data/rebalance_api.dart'; import 'data/rebalance_api.dart';
final rebalanceApiProvider = final rebalanceApiProvider = Provider<RebalanceApi>(
Provider<RebalanceApi>((ref) => RebalanceApi(ref.watch(apiProvider).dio)); (ref) => RebalanceApi(ref.watch(apiProvider).dio),
);
/// A portfolio the user can rebalance, derived from the scope list the analytics API /// A portfolio the user can rebalance, derived from the scope list the analytics API
/// already publishes (`portfolio:<id>`): there is no separate portfolios endpoint, and /// already publishes (`portfolio:<id>`): there is no separate portfolios endpoint, and
@@ -17,7 +18,9 @@ class PortfolioRef {
final String name; final String name;
} }
final portfoliosProvider = FutureProvider.autoDispose<List<PortfolioRef>>((ref) async { final portfoliosProvider = FutureProvider.autoDispose<List<PortfolioRef>>((
ref,
) async {
final scopes = await ref.watch(scopesProvider.future); final scopes = await ref.watch(scopesProvider.future);
return [ return [
for (final s in scopes) for (final s in scopes)
@@ -41,19 +44,24 @@ final whatIfCashProvider = StateProvider<String?>((ref) => null);
/// See `docs/ai/offline-cache.md`. No portfolio selected yet is a live, empty answer — not a /// See `docs/ai/offline-cache.md`. No portfolio selected yet is a live, empty answer — not a
/// stale one — so it is wrapped with `fetchedAt: null` rather than left unwrapped. /// stale one — so it is wrapped with `fetchedAt: null` rather than left unwrapped.
final targetsProvider = FutureProvider.autoDispose<Cached<TargetSet>>((ref) async { final targetsProvider = FutureProvider.autoDispose<Cached<TargetSet>>((
ref,
) async {
final id = ref.watch(selectedPortfolioProvider); final id = ref.watch(selectedPortfolioProvider);
final dimension = ref.watch(targetDimensionProvider); final dimension = ref.watch(targetDimensionProvider);
if (id == null) return Cached(TargetSet(dimension: dimension)); if (id == null) return Cached(TargetSet(dimension: dimension));
return ref.watch(rebalanceApiProvider).getTargets(id, dimension: dimension); return ref.watch(rebalanceApiProvider).getTargets(id, dimension: dimension);
}); });
final rebalancePlanProvider = FutureProvider.autoDispose<Cached<RebalancePlan?>>((ref) async { final rebalancePlanProvider =
FutureProvider.autoDispose<Cached<RebalancePlan?>>((ref) async {
final id = ref.watch(selectedPortfolioProvider); final id = ref.watch(selectedPortfolioProvider);
final dimension = ref.watch(targetDimensionProvider); final dimension = ref.watch(targetDimensionProvider);
final cash = ref.watch(whatIfCashProvider); final cash = ref.watch(whatIfCashProvider);
if (id == null) return const Cached(null); if (id == null) return const Cached(null);
return ref.watch(rebalanceApiProvider).plan(id, dimension: dimension, cashAvailable: cash); return ref
.watch(rebalanceApiProvider)
.plan(id, dimension: dimension, cashAvailable: cash);
}); });
/// After a successful PUT the numbers are recomputed on the server, so both sides of the /// After a successful PUT the numbers are recomputed on the server, so both sides of the
+49 -10
View File
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/cache/cached.dart'; import '../../core/cache/cached.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/stale_banner.dart'; import '../../core/widgets/stale_banner.dart';
import '../portfolio/labels.dart' show dimensionLabels; import '../portfolio/labels.dart' show dimensionLabels;
import '../portfolios/providers.dart' show portfolioListProvider;
import 'data/rebalance_api.dart'; import 'data/rebalance_api.dart';
import 'plan_tab.dart'; import 'plan_tab.dart';
import 'providers.dart'; import 'providers.dart';
@@ -45,7 +47,10 @@ class RebalancePage extends ConsumerWidget {
), ),
], ],
bottom: const TabBar( bottom: const TabBar(
tabs: [Tab(text: 'Целевые веса'), Tab(text: 'Рекомендации')], tabs: [
Tab(text: 'Целевые веса'),
Tab(text: 'Рекомендации'),
],
), ),
), ),
body: Column( body: Column(
@@ -61,21 +66,48 @@ class RebalancePage extends ConsumerWidget {
onRetry: () => ref.invalidate(portfoliosProvider), onRetry: () => ref.invalidate(portfoliosProvider),
data: (list) { data: (list) {
if (list.isEmpty) { if (list.isEmpty) {
return const EmptyState( // A portfolio can exist yet be absent here: this list comes from the
// analytics scopes, which only cover accounts that have ledger events.
final created =
ref
.watch(portfolioListProvider)
.valueOrNull
?.isNotEmpty ??
false;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
EmptyState(
icon: Icons.pie_chart_outline, icon: Icons.pie_chart_outline,
message: 'Портфелей пока нет.\n' message: created
'Ребалансировка считается по портфелю: заведите его в настройках счетов.', ? 'Портфели есть, но в них нет счетов с бумагами.\n'
'Добавьте брокерские счета: у счетов ZenMoney нет сделок, '
'считать по ним нечего.'
: 'Портфелей пока нет.\n'
'Ребалансировка считается по портфелю — набору счетов.',
),
FilledButton.icon(
onPressed: () => context.go('/portfolios'),
icon: Icon(created ? Icons.edit_outlined : Icons.add),
label: Text(
created ? 'Изменить портфели' : 'Создать портфель',
),
),
],
); );
} }
final selected = ref.watch(selectedPortfolioProvider); final selected = ref.watch(selectedPortfolioProvider);
if (selected == null || !list.any((p) => p.id == selected)) { if (selected == null || !list.any((p) => p.id == selected)) {
// pick the first portfolio once, after the list is known // pick the first portfolio once, after the list is known
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(selectedPortfolioProvider.notifier).state = list.first.id; ref.read(selectedPortfolioProvider.notifier).state =
list.first.id;
}); });
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return const TabBarView(children: [TargetsTab(), RebalancePlanTab()]); return const TabBarView(
children: [TargetsTab(), RebalancePlanTab()],
);
}, },
), ),
), ),
@@ -91,16 +123,23 @@ class _PortfolioSelector extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final portfolios = ref.watch(portfoliosProvider).valueOrNull ?? const <PortfolioRef>[]; final portfolios =
ref.watch(portfoliosProvider).valueOrNull ?? const <PortfolioRef>[];
final selected = ref.watch(selectedPortfolioProvider); final selected = ref.watch(selectedPortfolioProvider);
if (portfolios.length < 2 || selected == null) return const SizedBox.shrink(); if (portfolios.length < 2 || selected == null)
return const SizedBox.shrink();
return DropdownButtonHideUnderline( return DropdownButtonHideUnderline(
child: DropdownButton<int>( child: DropdownButton<int>(
value: portfolios.any((p) => p.id == selected) ? selected : portfolios.first.id, value: portfolios.any((p) => p.id == selected)
? selected
: portfolios.first.id,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
items: [ items: [
for (final p in portfolios) for (final p in portfolios)
DropdownMenuItem(value: p.id, child: Text(p.name, overflow: TextOverflow.ellipsis)), DropdownMenuItem(
value: p.id,
child: Text(p.name, overflow: TextOverflow.ellipsis),
),
], ],
onChanged: (v) { onChanged: (v) {
if (v != null) ref.read(selectedPortfolioProvider.notifier).state = v; if (v != null) ref.read(selectedPortfolioProvider.notifier).state = v;
+84 -30
View File
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/json.dart'; import '../../core/utils/json.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/section_card.dart'; import '../../core/widgets/section_card.dart';
import '../../core/widgets/help_tip.dart';
import '../portfolio/labels.dart' show assetClassLabels; import '../portfolio/labels.dart' show assetClassLabels;
import 'data/rebalance_api.dart'; import 'data/rebalance_api.dart';
import 'labels.dart'; import 'labels.dart';
@@ -38,7 +39,8 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
_dirty = false; _dirty = false;
} }
Decimal get _sum => sumDecimals((_draft ?? const []).map((t) => t.targetWeight)); Decimal get _sum =>
sumDecimals((_draft ?? const []).map((t) => t.targetWeight));
bool get _sumIsValid => (_sum - Decimal.one).abs() <= Decimal.parse('0.0001'); bool get _sumIsValid => (_sum - Decimal.one).abs() <= Decimal.parse('0.0001');
@@ -52,13 +54,17 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
try { try {
await ref await ref
.read(rebalanceApiProvider) .read(rebalanceApiProvider)
.putTargets(portfolioId, TargetSet(dimension: dimension, targets: draft)); .putTargets(
portfolioId,
TargetSet(dimension: dimension, targets: draft),
);
if (!mounted) return; if (!mounted) return;
_dirty = false; _dirty = false;
_seededFor = null; // refetch reseeds the draft from the saved set _seededFor = null; // refetch reseeds the draft from the saved set
invalidateRebalanceProviders(ref); invalidateRebalanceProviders(ref);
ScaffoldMessenger.of(context) ScaffoldMessenger.of(
.showSnackBar(const SnackBar(content: Text('Целевые веса сохранены'))); context,
).showSnackBar(const SnackBar(content: Text('Целевые веса сохранены')));
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
@@ -84,11 +90,16 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), padding: const EdgeInsets.fromLTRB(16, 16, 16, 88),
children: [ children: [
_SumBanner(sum: _sum, valid: _sumIsValid, serverSum: set.weightsSum), _SumBanner(
sum: _sum,
valid: _sumIsValid,
serverSum: set.weightsSum,
),
const SizedBox(height: 12), const SizedBox(height: 12),
SectionCard( SectionCard(
title: 'Целевые веса', title: 'Целевые веса',
subtitle: 'Вес — доля портфеля; допуск (band) — ширина коридора, ' subtitle:
'Вес — доля портфеля; допуск (band) — ширина коридора, '
'внутри которого сделки не предлагаются.', 'внутри которого сделки не предлагаются.',
child: Column( child: Column(
children: [ children: [
@@ -118,7 +129,11 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
onPressed: () => setState(() { onPressed: () => setState(() {
_draft = [ _draft = [
...draft, ...draft,
const TargetWeight(bucket: '', targetWeight: '0', band: '0.05'), const TargetWeight(
bucket: '',
targetWeight: '0',
band: '0.05',
),
]; ];
_dirty = true; _dirty = true;
}), }),
@@ -133,12 +148,18 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
Row( Row(
children: [ children: [
FilledButton.icon( FilledButton.icon(
onPressed: _saving || !_sumIsValid || draft.any((t) => t.bucket.isEmpty) onPressed:
_saving ||
!_sumIsValid ||
draft.any((t) => t.bucket.isEmpty)
? null ? null
: _save, : _save,
icon: _saving icon: _saving
? const SizedBox( ? const SizedBox(
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined), : const Icon(Icons.save_outlined),
label: const Text('Сохранить'), label: const Text('Сохранить'),
), ),
@@ -224,12 +245,15 @@ class _TargetRow extends StatefulWidget {
} }
class _TargetRowState extends State<_TargetRow> { class _TargetRowState extends State<_TargetRow> {
late final TextEditingController _weight = late final TextEditingController _weight = TextEditingController(
TextEditingController(text: shareToPercentText(widget.target.targetWeight)); text: shareToPercentText(widget.target.targetWeight),
late final TextEditingController _band = );
TextEditingController(text: shareToPercentText(widget.target.band)); late final TextEditingController _band = TextEditingController(
late final TextEditingController _bucket = text: shareToPercentText(widget.target.band),
TextEditingController(text: widget.target.bucket); );
late final TextEditingController _bucket = TextEditingController(
text: widget.target.bucket,
);
@override @override
void dispose() { void dispose() {
@@ -257,20 +281,32 @@ class _TargetRowState extends State<_TargetRow> {
child: knownBuckets.isEmpty child: knownBuckets.isEmpty
? TextField( ? TextField(
controller: _bucket, controller: _bucket,
decoration: const InputDecoration(labelText: 'Группа', isDense: true), decoration: const InputDecoration(
onChanged: (v) => widget.onChanged(widget.target.copyWith(bucket: v)), labelText: 'Группа',
isDense: true,
),
onChanged: (v) =>
widget.onChanged(widget.target.copyWith(bucket: v)),
) )
: DropdownButtonFormField<String>( : DropdownButtonFormField<String>(
initialValue: initialValue: knownBuckets.contains(widget.target.bucket)
knownBuckets.contains(widget.target.bucket) ? widget.target.bucket : null, ? widget.target.bucket
: null,
isExpanded: true, isExpanded: true,
decoration: const InputDecoration(labelText: 'Группа', isDense: true), decoration: const InputDecoration(
labelText: 'Группа',
isDense: true,
),
items: [ items: [
for (final b in knownBuckets) for (final b in knownBuckets)
DropdownMenuItem(value: b, child: Text(bucketLabelForKey('asset_class', b))), DropdownMenuItem(
value: b,
child: Text(bucketLabelForKey('asset_class', b)),
),
], ],
onChanged: (v) => onChanged: (v) => widget.onChanged(
widget.onChanged(widget.target.copyWith(bucket: v ?? '')), widget.target.copyWith(bucket: v ?? ''),
),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -278,11 +314,18 @@ class _TargetRowState extends State<_TargetRow> {
flex: 2, flex: 2,
child: TextField( child: TextField(
controller: _weight, controller: _weight,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(
decoration: const InputDecoration(labelText: 'Вес, %', isDense: true), decimal: true,
),
decoration: const InputDecoration(
labelText: 'Вес, %',
isDense: true,
),
onChanged: (v) { onChanged: (v) {
final share = percentTextToShare(v); final share = percentTextToShare(v);
widget.onChanged(widget.target.copyWith(targetWeight: share ?? '0')); widget.onChanged(
widget.target.copyWith(targetWeight: share ?? '0'),
);
}, },
), ),
), ),
@@ -291,10 +334,21 @@ class _TargetRowState extends State<_TargetRow> {
flex: 2, flex: 2,
child: TextField( child: TextField(
controller: _band, controller: _band,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(
decoration: const InputDecoration(labelText: 'Допуск, %', isDense: true), decimal: true,
onChanged: (v) => ),
widget.onChanged(widget.target.copyWith(band: percentTextToShare(v))), decoration: const InputDecoration(
labelText: 'Допуск, %',
isDense: true,
suffixIcon: HelpTip(
'Допуск (band) — ширина коридора вокруг целевого веса: пока фактическая доля '
'внутри него, сделки не предлагаются. ±5 % значит «не трогать, пока доля в '
'пределах 5 процентных пунктов от цели».',
),
),
onChanged: (v) => widget.onChanged(
widget.target.copyWith(band: percentTextToShare(v)),
),
), ),
), ),
IconButton( IconButton(
+5 -1
View File
@@ -26,7 +26,11 @@ String? percentTextToShare(String text) {
/// `"0.032"` → `"3,20 %"`, signed. Kept local to the rebalance screen because drift is a /// `"0.032"` → `"3,20 %"`, signed. Kept local to the rebalance screen because drift is a
/// share, not a return, and the portfolio helper would sign it the same way by accident. /// share, not a return, and the portfolio helper would sign it the same way by accident.
String formatShareAsPercent(String? share, {bool signed = false, int digits = 2}) { String formatShareAsPercent(
String? share, {
bool signed = false,
int digits = 2,
}) {
final d = share == null ? null : Decimal.tryParse(share); final d = share == null ? null : Decimal.tryParse(share);
if (d == null) return ''; if (d == null) return '';
final p = (d * _hundred).toDouble(); final p = (d * _hundred).toDouble();
+14 -4
View File
@@ -5,17 +5,27 @@ import '../../core/api/api_client.dart';
import '../../core/cache/cached.dart'; import '../../core/cache/cached.dart';
/// See `docs/ai/offline-cache.md`. /// See `docs/ai/offline-cache.md`.
final rulesListProvider = FutureProvider.autoDispose<Cached<List<RuleOut>>>((ref) async { final rulesListProvider = FutureProvider.autoDispose<Cached<List<RuleOut>>>((
ref,
) async {
final r = await ref.watch(apiProvider).getRulesApi().rulesList(); final r = await ref.watch(apiProvider).getRulesApi().rulesList();
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}); });
/// Enabled rules that matched nothing in the latest refresh — «устарело» here is a business /// Enabled rules that matched nothing in the latest refresh — «устарело» here is a business
/// concept (the rule looks dead), unrelated to the offline-cache staleness this file also /// concept (the rule looks dead), unrelated to the offline-cache staleness this file also
/// tracks; both happen to be named "stale" in their own domains. /// tracks; both happen to be named "stale" in their own domains.
final rulesStaleProvider = FutureProvider.autoDispose<Cached<List<RuleOut>>>((ref) async { final rulesStaleProvider = FutureProvider.autoDispose<Cached<List<RuleOut>>>((
ref,
) async {
final r = await ref.watch(apiProvider).getRulesApi().rulesStale(); final r = await ref.watch(apiProvider).getRulesApi().rulesStale();
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}); });
final staleRuleIdsProvider = Provider.autoDispose<Set<int>>((ref) { final staleRuleIdsProvider = Provider.autoDispose<Set<int>>((ref) {
+73 -23
View File
@@ -65,9 +65,13 @@ class RulesPage extends ConsumerWidget {
try { try {
final r = await ref.read(apiProvider).getRulesApi().rulesApply(); final r = await ref.read(apiProvider).getRulesApi().rulesApply();
final ok = r.data?.error == null; final ok = r.data?.error == null;
messenger.showSnackBar(SnackBar( messenger.showSnackBar(
content: Text(ok ? 'Правила применены' : 'Ошибка применения: ${r.data?.error}'), SnackBar(
)); content: Text(
ok ? 'Правила применены' : 'Ошибка применения: ${r.data?.error}',
),
),
);
} on DioException catch (e) { } on DioException catch (e) {
messenger.showSnackBar(SnackBar(content: Text(problemMessage(e)))); messenger.showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally { } finally {
@@ -113,11 +117,15 @@ class RulesPage extends ConsumerWidget {
return ListView( return ListView(
children: [ children: [
if (stale != null) StaleBanner(fetchedAt: stale), if (stale != null) StaleBanner(fetchedAt: stale),
const EmptyState(icon: Icons.rule_folder_outlined, message: 'Правил ещё нет.'), const EmptyState(
icon: Icons.rule_folder_outlined,
message: 'Правил ещё нет.',
),
], ],
); );
} }
final sorted = [...rows]..sort((a, b) => a.priority.compareTo(b.priority)); final sorted = [...rows]
..sort((a, b) => a.priority.compareTo(b.priority));
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@@ -138,7 +146,11 @@ class RulesPage extends ConsumerWidget {
} }
class _RuleTile extends ConsumerStatefulWidget { class _RuleTile extends ConsumerStatefulWidget {
const _RuleTile({required this.rule, required this.stale, required this.onChanged}); const _RuleTile({
required this.rule,
required this.stale,
required this.onChanged,
});
final RuleOut rule; final RuleOut rule;
final bool stale; final bool stale;
@@ -154,14 +166,18 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
Future<void> _toggle(bool enabled) async { Future<void> _toggle(bool enabled) async {
setState(() => _busy = true); setState(() => _busy = true);
try { try {
await ref.read(apiProvider).getRulesApi().rulesPatch( await ref
.read(apiProvider)
.getRulesApi()
.rulesPatch(
ruleId: widget.rule.id, ruleId: widget.rule.id,
rulePatch: RulePatch(enabled: enabled), rulePatch: RulePatch(enabled: enabled),
); );
widget.onChanged(); widget.onChanged();
} on DioException catch (e) { } on DioException catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally { } finally {
if (mounted) setState(() => _busy = false); if (mounted) setState(() => _busy = false);
} }
@@ -174,18 +190,28 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
title: const Text('Удалить правило?'), title: const Text('Удалить правило?'),
content: Text('«${widget.rule.pattern}» — действие необратимо.'), content: Text('«${widget.rule.pattern}» — действие необратимо.'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')), TextButton(
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Удалить')), onPressed: () => Navigator.pop(context, false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Удалить'),
),
], ],
), ),
); );
if (confirmed != true) return; if (confirmed != true) return;
try { try {
await ref.read(apiProvider).getRulesApi().rulesDelete(ruleId: widget.rule.id); await ref
.read(apiProvider)
.getRulesApi()
.rulesDelete(ruleId: widget.rule.id);
widget.onChanged(); widget.onChanged();
} on DioException catch (e) { } on DioException catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
} }
} }
@@ -196,7 +222,8 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
child: ListTile( child: ListTile(
onTap: () => showDialog( onTap: () => showDialog(
context: context, context: context,
builder: (_) => _RuleDialog(existing: rule, onSaved: widget.onChanged), builder: (_) =>
_RuleDialog(existing: rule, onSaved: widget.onChanged),
), ),
title: Row( title: Row(
children: [ children: [
@@ -222,7 +249,8 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
[ [
'${rule.pattern}${rule.value ?? ''}', '${rule.pattern}${rule.value ?? ''}',
'совпадений: ${rule.matchCount}', 'совпадений: ${rule.matchCount}',
if (rule.lastMatchedAt != null) 'последнее: ${ruDate(rule.lastMatchedAt!.toLocal())}', if (rule.lastMatchedAt != null)
'последнее: ${ruDate(rule.lastMatchedAt!.toLocal())}',
].join(' · '), ].join(' · '),
), ),
), ),
@@ -230,7 +258,10 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Switch(value: rule.enabled, onChanged: _busy ? null : _toggle), Switch(value: rule.enabled, onChanged: _busy ? null : _toggle),
IconButton(icon: const Icon(Icons.delete_outline), onPressed: _delete), IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: _delete,
),
], ],
), ),
), ),
@@ -317,7 +348,8 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> {
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
} on DioException catch (e) { } on DioException catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e)))); ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally { } finally {
if (mounted) setState(() => _saving = false); if (mounted) setState(() => _saving = false);
} }
@@ -337,7 +369,10 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> {
DropdownButtonFormField<RuleKind>( DropdownButtonFormField<RuleKind>(
initialValue: _kind, initialValue: _kind,
decoration: const InputDecoration(labelText: 'Тип правила'), decoration: const InputDecoration(labelText: 'Тип правила'),
items: [for (final k in _kinds) DropdownMenuItem(value: k, child: Text(ruleKindLabel(k)))], items: [
for (final k in _kinds)
DropdownMenuItem(value: k, child: Text(ruleKindLabel(k))),
],
onChanged: (v) => setState(() => _kind = v!), onChanged: (v) => setState(() => _kind = v!),
), ),
DropdownButtonFormField<RuleMatchType>( DropdownButtonFormField<RuleMatchType>(
@@ -345,18 +380,27 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> {
decoration: const InputDecoration(labelText: 'Совпадение по'), decoration: const InputDecoration(labelText: 'Совпадение по'),
items: [ items: [
for (final t in _matchTypes) for (final t in _matchTypes)
DropdownMenuItem(value: t, child: Text(ruleMatchTypeLabel(t))), DropdownMenuItem(
value: t,
child: Text(ruleMatchTypeLabel(t)),
),
], ],
onChanged: (v) => setState(() => _matchType = v!), onChanged: (v) => setState(() => _matchType = v!),
), ),
TextFormField( TextFormField(
controller: _pattern, controller: _pattern,
decoration: const InputDecoration(labelText: 'Шаблон (pattern)'), decoration: const InputDecoration(
validator: (v) => (v == null || v.trim().isEmpty) ? 'Обязательное поле' : null, labelText: 'Шаблон (pattern)',
),
validator: (v) => (v == null || v.trim().isEmpty)
? 'Обязательное поле'
: null,
), ),
TextFormField( TextFormField(
controller: _value, controller: _value,
decoration: const InputDecoration(labelText: 'Значение (value)'), decoration: const InputDecoration(
labelText: 'Значение (value)',
),
), ),
TextFormField( TextFormField(
controller: _note, controller: _note,
@@ -378,8 +422,14 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> {
), ),
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Отмена')), TextButton(
FilledButton(onPressed: _saving ? null : _save, child: const Text('Сохранить')), onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: const Text('Сохранить'),
),
], ],
); );
} }
+18 -6
View File
@@ -41,7 +41,8 @@ class TaxRow {
final String? taxableBaseRub; final String? taxableBaseRub;
final String? estimatedTaxRub; final String? estimatedTaxRub;
String get title => accountName ?? (accountId == null ? 'Итого' : 'Счёт #$accountId'); String get title =>
accountName ?? (accountId == null ? 'Итого' : 'Счёт #$accountId');
static TaxRow fromJson(Map<String, dynamic> json) => TaxRow( static TaxRow fromJson(Map<String, dynamic> json) => TaxRow(
accountId: asInt(json['account_id']), accountId: asInt(json['account_id']),
@@ -83,7 +84,9 @@ class TaxSummary {
final totals = asObject(json['totals']); final totals = asObject(json['totals']);
return TaxSummary( return TaxSummary(
year: asInt(json['year']) ?? DateTime.now().year, year: asInt(json['year']) ?? DateTime.now().year,
estimated: json.containsKey('estimated') ? asBool(json['estimated']) : true, estimated: json.containsKey('estimated')
? asBool(json['estimated'])
: true,
taxRate: asString(json['tax_rate']), taxRate: asString(json['tax_rate']),
accounts: asObjects(json['accounts']).map(TaxRow.fromJson).toList(), accounts: asObjects(json['accounts']).map(TaxRow.fromJson).toList(),
totals: totals == null ? null : TaxRow.fromJson(totals), totals: totals == null ? null : TaxRow.fromJson(totals),
@@ -128,7 +131,8 @@ class TaxLot {
/// horizon at which a person can still decide to wait. /// horizon at which a person can still decide to wait.
bool get nearLdv => !ldvEligible && daysToLdv != null && daysToLdv! <= 183; bool get nearLdv => !ldvEligible && daysToLdv != null && daysToLdv! <= 183;
String get title => ticker ?? (instrumentId == null ? '#$lotId' : '#$instrumentId'); String get title =>
ticker ?? (instrumentId == null ? '#$lotId' : '#$instrumentId');
static TaxLot fromJson(Map<String, dynamic> json) => TaxLot( static TaxLot fromJson(Map<String, dynamic> json) => TaxLot(
lotId: asInt(json['lot_id']) ?? 0, lotId: asInt(json['lot_id']) ?? 0,
@@ -156,12 +160,18 @@ class TaxApi {
/// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly /// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly
/// (there is no generated `Response<T>` for the screen to unwrap `r.cached` from). /// (there is no generated `Response<T>` for the screen to unwrap `r.cached` from).
Future<Cached<TaxSummary>> summary({required int year, int? accountId}) async { Future<Cached<TaxSummary>> summary({
required int year,
int? accountId,
}) async {
final r = await _dio.get<Map<String, dynamic>>( final r = await _dio.get<Map<String, dynamic>>(
_base, _base,
queryParameters: {'year': year, 'account_id': ?accountId}, queryParameters: {'year': year, 'account_id': ?accountId},
); );
return Cached(TaxSummary.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?); return Cached(
TaxSummary.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
} }
Future<Cached<List<TaxLot>>> lots({required int year, int? accountId}) async { Future<Cached<List<TaxLot>>> lots({required int year, int? accountId}) async {
@@ -169,7 +179,9 @@ class TaxApi {
'$_base/lots', '$_base/lots',
queryParameters: {'year': year, 'account_id': ?accountId}, queryParameters: {'year': year, 'account_id': ?accountId},
); );
final lots = asObjects((r.data ?? const {})['lots']).map(TaxLot.fromJson).toList(); final lots = asObjects((r.data ?? const {})['lots'])
.map(TaxLot.fromJson)
.toList();
return Cached(lots, fetchedAt: r.extra['fetchedAt'] as DateTime?); return Cached(lots, fetchedAt: r.extra['fetchedAt'] as DateTime?);
} }
} }
+113 -33
View File
@@ -1,4 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/widgets/help_tip.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -67,9 +70,12 @@ class _TaxLotsTabState extends ConsumerState<TaxLotsTab> {
color: Theme.of(context).colorScheme.tertiaryContainer, color: Theme.of(context).colorScheme.tertiaryContainer,
child: ListTile( child: ListTile(
leading: const Icon(Icons.schedule), leading: const Icon(Icons.schedule),
title: Text('До ЛДВ меньше полугода: ${near.length} лот(ов)'), title: Text(
'До ЛДВ меньше полугода: ${near.length} лот(ов)',
),
subtitle: const Text( subtitle: const Text(
'Продажа до этой даты облагается налогом на весь прирост.'), 'Продажа до этой даты облагается налогом на весь прирост.',
),
trailing: FilterChip( trailing: FilterChip(
label: const Text('только они'), label: const Text('только они'),
selected: _onlyNearLdv, selected: _onlyNearLdv,
@@ -80,7 +86,8 @@ class _TaxLotsTabState extends ConsumerState<TaxLotsTab> {
const SizedBox(height: 12), const SizedBox(height: 12),
SectionCard( SectionCard(
title: 'Открытые лоты', title: 'Открытые лоты',
subtitle: 'налог при продаже сегодня — оценка по ставке из сводки', subtitle:
'налог при продаже сегодня — оценка по ставке из сводки',
child: _LotsTable(rows: rows), child: _LotsTable(rows: rows),
), ),
], ],
@@ -104,71 +111,144 @@ class _LotsTable extends StatelessWidget {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: DataTable( child: DataTable(
columns: [ columns: [
DataColumn(label: Text('Бумага', style: headerStyle)), DataColumn(label: TermLabel('Бумага', style: headerStyle)),
DataColumn(label: Text('Куплен', style: headerStyle)), DataColumn(label: TermLabel('Куплен', style: headerStyle)),
DataColumn(label: Text('Кол-во', style: headerStyle), numeric: true), DataColumn(
DataColumn(label: Text('Стоимость', style: headerStyle), numeric: true), label: TermLabel(
DataColumn(label: Text('Рынок', style: headerStyle), numeric: true), 'Кол-во',
DataColumn(label: Text('Нереализ.', style: headerStyle), numeric: true), style: headerStyle,
DataColumn(label: Text('Дата ЛДВ', style: headerStyle)), alignment: MainAxisAlignment.end,
DataColumn(label: Text('Дней до ЛДВ', style: headerStyle), numeric: true), ),
DataColumn(label: Text('Налог при продаже', style: headerStyle), numeric: true), numeric: true,
),
DataColumn(
label: TermLabel(
'Стоимость',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Рынок',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Нереализ.',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(label: TermLabel('Дата ЛДВ', style: headerStyle)),
DataColumn(
label: TermLabel(
'Дней до ЛДВ',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Налог при продаже',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
], ],
rows: [ rows: [
for (final l in rows) for (final l in rows)
DataRow( DataRow(
color: l.nearLdv color: l.nearLdv
? WidgetStatePropertyAll( ? WidgetStatePropertyAll(
theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5)) theme.colorScheme.tertiaryContainer.withValues(
alpha: 0.5,
),
)
: null, : null,
onSelectChanged: l.instrumentId == null onSelectChanged: l.instrumentId == null
? null ? null
: (_) => context.push('/portfolio/instrument/${l.instrumentId}'), : (_) =>
context.push('/portfolio/instrument/${l.instrumentId}'),
cells: [ cells: [
DataCell(Row( DataCell(
Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(l.title), Text(l.title),
if (l.nearLdv) ...[ if (l.nearLdv) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Tooltip( Tooltip(
message: 'До ЛДВ осталось ${l.daysToLdv} дн. — ' message:
'До ЛДВ осталось ${l.daysToLdv} дн. — '
'продажа сейчас облагается налогом полностью', 'продажа сейчас облагается налогом полностью',
child: Icon(Icons.schedule, size: 16, color: theme.colorScheme.error), child: Icon(
Icons.schedule,
size: 16,
color: theme.colorScheme.error,
),
), ),
], ],
], ],
)), ),
),
DataCell(Text(l.openDate == null ? '' : ruDate(l.openDate!))), DataCell(Text(l.openDate == null ? '' : ruDate(l.openDate!))),
DataCell(Text(l.qtyRemaining == null ? '' : formatQty(l.qtyRemaining!))), DataCell(
DataCell(l.costRub == null Text(
l.qtyRemaining == null ? '' : formatQty(l.qtyRemaining!),
),
),
DataCell(
l.costRub == null
? const Text('') ? const Text('')
: MoneyText(l.costRub!, currency: 'RUB')), : MoneyText(l.costRub!, currency: 'RUB'),
DataCell(l.marketValueRub == null ),
DataCell(
l.marketValueRub == null
? const Text('') ? const Text('')
: MoneyText(l.marketValueRub!, currency: 'RUB')), : MoneyText(l.marketValueRub!, currency: 'RUB'),
DataCell(l.unrealizedGainRub == null ),
DataCell(
l.unrealizedGainRub == null
? const Text('') ? const Text('')
: MoneyText( : MoneyText(
l.unrealizedGainRub!, l.unrealizedGainRub!,
currency: 'RUB', currency: 'RUB',
style: TextStyle(color: signColor(context, l.unrealizedGainRub)), style: TextStyle(
)), color: signColor(context, l.unrealizedGainRub),
DataCell(l.ldvEligible ),
),
),
DataCell(
l.ldvEligible
? const Text('уже действует') ? const Text('уже действует')
: Text(l.ldvDate == null ? '' : ruDate(l.ldvDate!))), : Text(l.ldvDate == null ? '' : ruDate(l.ldvDate!)),
DataCell(l.ldvEligible ),
DataCell(
l.ldvEligible
? const Text('') ? const Text('')
: Text( : Text(
l.daysToLdv == null ? '' : '${l.daysToLdv}', l.daysToLdv == null ? '' : '${l.daysToLdv}',
style: l.nearLdv style: l.nearLdv
? TextStyle( ? TextStyle(
color: theme.colorScheme.error, fontWeight: FontWeight.w600) color: theme.colorScheme.error,
fontWeight: FontWeight.w600,
)
: null, : null,
)), ),
DataCell(l.taxIfSoldNowRub == null ),
DataCell(
l.taxIfSoldNowRub == null
? const Text('') ? const Text('')
: MoneyText(l.taxIfSoldNowRub!, currency: 'RUB')), : MoneyText(l.taxIfSoldNowRub!, currency: 'RUB'),
),
], ],
), ),
], ],
+15 -5
View File
@@ -4,7 +4,9 @@ import '../../core/api/api_client.dart';
import '../../core/cache/cached.dart'; import '../../core/cache/cached.dart';
import 'data/tax_api.dart'; import 'data/tax_api.dart';
final taxApiProvider = Provider<TaxApi>((ref) => TaxApi(ref.watch(apiProvider).dio)); final taxApiProvider = Provider<TaxApi>(
(ref) => TaxApi(ref.watch(apiProvider).dio),
);
final taxYearProvider = StateProvider<int>((ref) => DateTime.now().year); final taxYearProvider = StateProvider<int>((ref) => DateTime.now().year);
@@ -12,15 +14,23 @@ final taxYearProvider = StateProvider<int>((ref) => DateTime.now().year);
final taxAccountProvider = StateProvider<int?>((ref) => null); final taxAccountProvider = StateProvider<int?>((ref) => null);
/// See `docs/ai/offline-cache.md`. /// See `docs/ai/offline-cache.md`.
final taxSummaryProvider = FutureProvider.autoDispose<Cached<TaxSummary>>((ref) async { final taxSummaryProvider = FutureProvider.autoDispose<Cached<TaxSummary>>((
return ref.watch(taxApiProvider).summary( ref,
) async {
return ref
.watch(taxApiProvider)
.summary(
year: ref.watch(taxYearProvider), year: ref.watch(taxYearProvider),
accountId: ref.watch(taxAccountProvider), accountId: ref.watch(taxAccountProvider),
); );
}); });
final taxLotsProvider = FutureProvider.autoDispose<Cached<List<TaxLot>>>((ref) async { final taxLotsProvider = FutureProvider.autoDispose<Cached<List<TaxLot>>>((
return ref.watch(taxApiProvider).lots( ref,
) async {
return ref
.watch(taxApiProvider)
.lots(
year: ref.watch(taxYearProvider), year: ref.watch(taxYearProvider),
accountId: ref.watch(taxAccountProvider), accountId: ref.watch(taxAccountProvider),
); );
+81 -12
View File
@@ -1,4 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/widgets/help_tip.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/async_value_view.dart';
@@ -30,7 +33,10 @@ class TaxSummaryTab extends ConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
Text('${data.year} год', style: Theme.of(context).textTheme.titleLarge), Text(
'${data.year} год',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(width: 12), const SizedBox(width: 12),
if (data.estimated) if (data.estimated)
const Chip( const Chip(
@@ -40,7 +46,9 @@ class TaxSummaryTab extends ConsumerWidget {
), ),
const Spacer(), const Spacer(),
if (data.taxRate != null) if (data.taxRate != null)
Text('ставка ${formatPercent(data.taxRate, signed: false)}'), Text(
'ставка ${formatPercent(data.taxRate, signed: false)}',
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -124,15 +132,72 @@ class _AccountsTable extends StatelessWidget {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: DataTable( child: DataTable(
columns: [ columns: [
DataColumn(label: Text('Счёт', style: headerStyle)), DataColumn(label: TermLabel('Счёт', style: headerStyle)),
DataColumn(label: Text('Дивиденды', style: headerStyle), numeric: true), DataColumn(
DataColumn(label: Text('Купоны', style: headerStyle), numeric: true), label: TermLabel(
DataColumn(label: Text('Удержано', style: headerStyle), numeric: true), 'Дивиденды',
DataColumn(label: Text('Прибыль', style: headerStyle), numeric: true), style: headerStyle,
DataColumn(label: Text('Убыток', style: headerStyle), numeric: true), alignment: MainAxisAlignment.end,
DataColumn(label: Text('ЛДВ', style: headerStyle), numeric: true), ),
DataColumn(label: Text('База', style: headerStyle), numeric: true), numeric: true,
DataColumn(label: Text('Налог (оценка)', style: headerStyle), numeric: true), ),
DataColumn(
label: TermLabel(
'Купоны',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Удержано',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Прибыль',
hint: 'Сумма положительных результатов по проданным бумагам за год (до вычета убытков).',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Убыток',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'ЛДВ',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'База',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Налог (оценка)',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
], ],
rows: [ rows: [
for (final r in rows) _row(context, r, bold: false), for (final r in rows) _row(context, r, bold: false),
@@ -149,7 +214,11 @@ class _AccountsTable extends StatelessWidget {
: MoneyText( : MoneyText(
v, v,
currency: 'RUB', currency: 'RUB',
style: signed ? (style ?? const TextStyle()).copyWith(color: signColor(context, v)) : style, style: signed
? (style ?? const TextStyle()).copyWith(
color: signColor(context, v),
)
: style,
); );
return DataRow( return DataRow(
+8 -6
View File
@@ -37,7 +37,10 @@ class TaxPage extends ConsumerWidget {
), ),
], ],
bottom: const TabBar( bottom: const TabBar(
tabs: [Tab(text: 'Сводка за год'), Tab(text: 'Лоты и ЛДВ')], tabs: [
Tab(text: 'Сводка за год'),
Tab(text: 'Лоты и ЛДВ'),
],
), ),
), ),
body: Column( body: Column(
@@ -48,7 +51,9 @@ class TaxPage extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: StaleBanner(fetchedAt: stale), child: StaleBanner(fetchedAt: stale),
), ),
const Expanded(child: TabBarView(children: [TaxSummaryTab(), TaxLotsTab()])), const Expanded(
child: TabBarView(children: [TaxSummaryTab(), TaxLotsTab()]),
),
], ],
), ),
), ),
@@ -75,10 +80,7 @@ class EstimateBanner extends ConsumerWidget {
const Icon(Icons.info_outline, size: 18), const Icon(Icons.info_outline, size: 18),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(text, style: Theme.of(context).textTheme.bodySmall),
text,
style: Theme.of(context).textTheme.bodySmall,
),
), ),
], ],
), ),
+20 -5
View File
@@ -22,7 +22,12 @@ class TransactionsFilter {
final FlowType? flowType; final FlowType? flowType;
bool get isEmpty => bool get isEmpty =>
q == null && from == null && to == null && accountId == null && categoryId == null && flowType == null; q == null &&
from == null &&
to == null &&
accountId == null &&
categoryId == null &&
flowType == null;
TransactionsFilter copyWith({ TransactionsFilter copyWith({
String? Function()? q, String? Function()? q,
@@ -98,7 +103,9 @@ class TransactionsController extends Notifier<TransactionsState> {
return const TransactionsState(loading: true); return const TransactionsState(loading: true);
} }
Future<void> setFilter(TransactionsFilter Function(TransactionsFilter) update) { Future<void> setFilter(
TransactionsFilter Function(TransactionsFilter) update,
) {
filter = update(filter); filter = update(filter);
return refresh(); return refresh();
} }
@@ -106,7 +113,10 @@ class TransactionsController extends Notifier<TransactionsState> {
Future<void> refresh() async { Future<void> refresh() async {
state = state.copyWith(loading: true, clearError: true); state = state.copyWith(loading: true, clearError: true);
try { try {
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList( final r = await ref
.read(apiProvider)
.getTransactionsApi()
.transactionsList(
from: filter.from, from: filter.from,
to: filter.to, to: filter.to,
accountId: filter.accountId, accountId: filter.accountId,
@@ -133,7 +143,10 @@ class TransactionsController extends Notifier<TransactionsState> {
state = state.copyWith(loadingMore: true, clearError: true); state = state.copyWith(loadingMore: true, clearError: true);
try { try {
final next = state.page + 1; final next = state.page + 1;
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList( final r = await ref
.read(apiProvider)
.getTransactionsApi()
.transactionsList(
from: filter.from, from: filter.from,
to: filter.to, to: filter.to,
accountId: filter.accountId, accountId: filter.accountId,
@@ -157,4 +170,6 @@ class TransactionsController extends Notifier<TransactionsState> {
} }
final transactionsControllerProvider = final transactionsControllerProvider =
NotifierProvider<TransactionsController, TransactionsState>(TransactionsController.new); NotifierProvider<TransactionsController, TransactionsState>(
TransactionsController.new,
);
@@ -7,12 +7,22 @@ import '../../core/widgets/money_text.dart';
/// The amount/currency/RUB-equivalent a transaction is shown with, chosen by /// The amount/currency/RUB-equivalent a transaction is shown with, chosen by
/// its [FlowType]: the outgoing leg for expenses and transfers out, the /// its [FlowType]: the outgoing leg for expenses and transfers out, the
/// incoming leg for income. /// incoming leg for income.
({String amount, String currency, String? rub}) primaryAmount(TransactionOut t) { ({String amount, String currency, String? rub}) primaryAmount(
TransactionOut t,
) {
switch (t.flowType) { switch (t.flowType) {
case FlowType.income: case FlowType.income:
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub); return (
amount: t.income,
currency: t.incomeCurrency ?? 'RUB',
rub: t.incomeRub,
);
case FlowType.expense: case FlowType.expense:
return (amount: t.outcome, currency: t.outcomeCurrency ?? 'RUB', rub: t.outcomeRub); return (
amount: t.outcome,
currency: t.outcomeCurrency ?? 'RUB',
rub: t.outcomeRub,
);
case FlowType.internalTransfer: case FlowType.internalTransfer:
case FlowType.savingsTransfer: case FlowType.savingsTransfer:
case FlowType.brokerExternalFlow: case FlowType.brokerExternalFlow:
@@ -20,22 +30,33 @@ import '../../core/widgets/money_text.dart';
case FlowType.deleted: case FlowType.deleted:
case FlowType.unknownDefaultOpenApi: case FlowType.unknownDefaultOpenApi:
if (t.outcome != '0' && t.outcomeCurrency != null) { if (t.outcome != '0' && t.outcomeCurrency != null) {
return (amount: t.outcome, currency: t.outcomeCurrency!, rub: t.outcomeRub); return (
amount: t.outcome,
currency: t.outcomeCurrency!,
rub: t.outcomeRub,
);
} }
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub); return (
amount: t.income,
currency: t.incomeCurrency ?? 'RUB',
rub: t.incomeRub,
);
} }
} }
(IconData, Color) flowTypeIcon(FlowType type, ColorScheme scheme) => switch (type) { (IconData, Color) flowTypeIcon(FlowType type, ColorScheme scheme) =>
switch (type) {
FlowType.income => (Icons.arrow_circle_down_outlined, Colors.green), FlowType.income => (Icons.arrow_circle_down_outlined, Colors.green),
FlowType.expense => (Icons.arrow_circle_up_outlined, scheme.error), FlowType.expense => (Icons.arrow_circle_up_outlined, scheme.error),
FlowType.internalTransfer => (Icons.swap_horiz, scheme.primary), FlowType.internalTransfer => (Icons.swap_horiz, scheme.primary),
FlowType.savingsTransfer => (Icons.savings_outlined, Colors.amber.shade800), FlowType.savingsTransfer => (
FlowType.brokerExternalFlow => (Icons.trending_up, Colors.deepPurple), Icons.savings_outlined,
FlowType.other || FlowType.deleted || FlowType.unknownDefaultOpenApi => ( Colors.amber.shade800,
Icons.help_outline,
scheme.outline,
), ),
FlowType.brokerExternalFlow => (Icons.trending_up, Colors.deepPurple),
FlowType.other ||
FlowType.deleted ||
FlowType.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline),
}; };
/// One row of the Операции list: date, payee, category, native amount with /// One row of the Операции list: date, payee, category, native amount with
@@ -59,7 +80,9 @@ class TransactionRow extends StatelessWidget {
final (icon, color) = flowTypeIcon(t.flowType, scheme); final (icon, color) = flowTypeIcon(t.flowType, scheme);
final amount = primaryAmount(t); final amount = primaryAmount(t);
final showRub = amount.currency != 'RUB' && amount.rub != null; final showRub = amount.currency != 'RUB' && amount.rub != null;
final payee = t.payeeCanonical?.isNotEmpty == true ? t.payeeCanonical! : (t.payee ?? ''); final payee = t.payeeCanonical?.isNotEmpty == true
? t.payeeCanonical!
: (t.payee ?? '');
return ListTile( return ListTile(
onTap: onTap, onTap: onTap,
@@ -74,7 +97,8 @@ class TransactionRow extends StatelessWidget {
if (showRub) if (showRub)
Text( Text(
MoneyText.format(amount.rub!, 'RUB'), MoneyText.format(amount.rub!, 'RUB'),
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.outline), style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: scheme.outline),
), ),
], ],
), ),
@@ -60,7 +60,8 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
} }
void _onScroll() { void _onScroll() {
if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) { if (_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 200) {
ref.read(transactionsControllerProvider.notifier).loadMore(); ref.read(transactionsControllerProvider.notifier).loadMore();
} }
} }
@@ -68,8 +69,11 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
void _onSearchChanged(String value) { void _onSearchChanged(String value) {
_debounce?.cancel(); _debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () { _debounce = Timer(const Duration(milliseconds: 400), () {
ref.read(transactionsControllerProvider.notifier).setFilter( ref
(f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()), .read(transactionsControllerProvider.notifier)
.setFilter(
(f) =>
f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
); );
}); });
} }
@@ -87,16 +91,18 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
helpText: 'Период', helpText: 'Период',
); );
if (picked != null) { if (picked != null) {
ref.read(transactionsControllerProvider.notifier).setFilter( ref
.read(transactionsControllerProvider.notifier)
.setFilter(
(f) => f.copyWith(from: () => picked.start, to: () => picked.end), (f) => f.copyWith(from: () => picked.start, to: () => picked.end),
); );
} }
} }
void _clearDateRange() { void _clearDateRange() {
ref.read(transactionsControllerProvider.notifier).setFilter( ref
(f) => f.copyWith(from: () => null, to: () => null), .read(transactionsControllerProvider.notifier)
); .setFilter((f) => f.copyWith(from: () => null, to: () => null));
} }
void _showDetail(TransactionOut t) { void _showDetail(TransactionOut t) {
@@ -116,10 +122,14 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = ref.watch(transactionsControllerProvider); final state = ref.watch(transactionsControllerProvider);
final controllerFilter = ref.watch(transactionsControllerProvider.notifier).filter; final controllerFilter = ref
.watch(transactionsControllerProvider.notifier)
.filter;
final categoryNames = ref.watch(categoryNamesProvider); final categoryNames = ref.watch(categoryNamesProvider);
final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[]; final accounts =
final categories = ref.watch(categoriesListProvider).valueOrNull ?? const <CategoryOut>[]; ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
final categories =
ref.watch(categoriesListProvider).valueOrNull ?? const <CategoryOut>[];
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Операции')), appBar: AppBar(title: const Text('Операции')),
@@ -149,26 +159,36 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
InputChip( InputChip(
avatar: const Icon(Icons.date_range, size: 18), avatar: const Icon(Icons.date_range, size: 18),
label: Text( label: Text(
controllerFilter.from != null && controllerFilter.to != null controllerFilter.from != null &&
controllerFilter.to != null
? '${ruDate(controllerFilter.from!)} ${ruDate(controllerFilter.to!)}' ? '${ruDate(controllerFilter.from!)} ${ruDate(controllerFilter.to!)}'
: 'Период', : 'Период',
), ),
onPressed: _pickDateRange, onPressed: _pickDateRange,
onDeleted: controllerFilter.from != null ? _clearDateRange : null, onDeleted: controllerFilter.from != null
? _clearDateRange
: null,
), ),
SizedBox( SizedBox(
width: 180, width: 180,
child: DropdownButtonFormField<int?>( child: DropdownButtonFormField<int?>(
initialValue: controllerFilter.accountId, initialValue: controllerFilter.accountId,
isDense: true, isDense: true,
decoration: const InputDecoration(labelText: 'Счёт', isDense: true), decoration: const InputDecoration(
items: [ labelText: 'Счёт',
const DropdownMenuItem(value: null, child: Text('Все счета')), isDense: true,
for (final a in accounts) DropdownMenuItem(value: a.id, child: Text(a.name)),
],
onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(accountId: () => v),
), ),
items: [
const DropdownMenuItem(
value: null,
child: Text('Все счета'),
),
for (final a in accounts)
DropdownMenuItem(value: a.id, child: Text(a.name)),
],
onChanged: (v) => ref
.read(transactionsControllerProvider.notifier)
.setFilter((f) => f.copyWith(accountId: () => v)),
), ),
), ),
SizedBox( SizedBox(
@@ -176,14 +196,21 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
child: DropdownButtonFormField<int?>( child: DropdownButtonFormField<int?>(
initialValue: controllerFilter.categoryId, initialValue: controllerFilter.categoryId,
isDense: true, isDense: true,
decoration: const InputDecoration(labelText: 'Категория', isDense: true), decoration: const InputDecoration(
items: [ labelText: 'Категория',
const DropdownMenuItem(value: null, child: Text('Все категории')), isDense: true,
for (final c in categories) DropdownMenuItem(value: c.id, child: Text(c.name)),
],
onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(categoryId: () => v),
), ),
items: [
const DropdownMenuItem(
value: null,
child: Text('Все категории'),
),
for (final c in categories)
DropdownMenuItem(value: c.id, child: Text(c.name)),
],
onChanged: (v) => ref
.read(transactionsControllerProvider.notifier)
.setFilter((f) => f.copyWith(categoryId: () => v)),
), ),
), ),
], ],
@@ -195,17 +222,17 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
ChoiceChip( ChoiceChip(
label: const Text('Все типы'), label: const Text('Все типы'),
selected: controllerFilter.flowType == null, selected: controllerFilter.flowType == null,
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter( onSelected: (_) => ref
(f) => f.copyWith(flowType: () => null), .read(transactionsControllerProvider.notifier)
), .setFilter((f) => f.copyWith(flowType: () => null)),
), ),
for (final ft in _flowTypes) for (final ft in _flowTypes)
ChoiceChip( ChoiceChip(
label: Text(_flowTypeLabel(ft)), label: Text(_flowTypeLabel(ft)),
selected: controllerFilter.flowType == ft, selected: controllerFilter.flowType == ft,
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter( onSelected: (_) => ref
(f) => f.copyWith(flowType: () => ft), .read(transactionsControllerProvider.notifier)
), .setFilter((f) => f.copyWith(flowType: () => ft)),
), ),
], ],
), ),
@@ -230,12 +257,17 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32), Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 32,
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('${state.error}', textAlign: TextAlign.center), Text('${state.error}', textAlign: TextAlign.center),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.tonal( FilledButton.tonal(
onPressed: () => ref.read(transactionsControllerProvider.notifier).refresh(), onPressed: () =>
ref.read(transactionsControllerProvider.notifier).refresh(),
child: const Text('Повторить'), child: const Text('Повторить'),
), ),
], ],
@@ -250,7 +282,8 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
); );
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(transactionsControllerProvider.notifier).refresh(), onRefresh: () =>
ref.read(transactionsControllerProvider.notifier).refresh(),
child: ListView.builder( child: ListView.builder(
controller: _scrollController, controller: _scrollController,
itemCount: state.items.length + 1, itemCount: state.items.length + 1,
@@ -263,7 +296,9 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
child: state.loadingMore child: state.loadingMore
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: TextButton( : TextButton(
onPressed: () => ref.read(transactionsControllerProvider.notifier).loadMore(), onPressed: () => ref
.read(transactionsControllerProvider.notifier)
.loadMore(),
child: const Text('Ещё'), child: const Text('Ещё'),
), ),
), ),
@@ -272,7 +307,9 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
final t = state.items[index]; final t = state.items[index];
return TransactionRow( return TransactionRow(
transaction: t, transaction: t,
categoryName: t.categoryId != null ? categoryNames[t.categoryId] : null, categoryName: t.categoryId != null
? categoryNames[t.categoryId]
: null,
onTap: () => _showDetail(t), onTap: () => _showDetail(t),
); );
}, },
@@ -298,24 +335,43 @@ class _TransactionDetailSheet extends StatelessWidget {
final rows = <(String, String)>[ final rows = <(String, String)>[
('Дата', ruDate(t.date)), ('Дата', ruDate(t.date)),
('Плательщик', t.payee ?? ''), ('Плательщик', t.payee ?? ''),
if (t.payeeCanonical != null && t.payeeCanonical != t.payee) ('Канонический', t.payeeCanonical!), if (t.payeeCanonical != null && t.payeeCanonical != t.payee)
('Канонический', t.payeeCanonical!),
('Комментарий', t.comment ?? ''), ('Комментарий', t.comment ?? ''),
('Категория', t.categoryId != null ? (categoryNames[t.categoryId] ?? '#${t.categoryId}') : 'Без категории'), (
'Категория',
t.categoryId != null
? (categoryNames[t.categoryId] ?? '#${t.categoryId}')
: 'Без категории',
),
('Тип', _flowTypeLabel(t.flowType)), ('Тип', _flowTypeLabel(t.flowType)),
if (t.outcome != '0') if (t.outcome != '0')
('Списание', '${MoneyText.format(t.outcome, t.outcomeCurrency ?? 'RUB')}' (
'${t.outcomeRub != null ? ' (${MoneyText.format(t.outcomeRub!, 'RUB')})' : ''}'), 'Списание',
'${MoneyText.format(t.outcome, t.outcomeCurrency ?? 'RUB')}'
'${t.outcomeRub != null ? ' (${MoneyText.format(t.outcomeRub!, 'RUB')})' : ''}',
),
if (t.income != '0') if (t.income != '0')
('Зачисление', '${MoneyText.format(t.income, t.incomeCurrency ?? 'RUB')}' (
'${t.incomeRub != null ? ' (${MoneyText.format(t.incomeRub!, 'RUB')})' : ''}'), 'Зачисление',
'${MoneyText.format(t.income, t.incomeCurrency ?? 'RUB')}'
'${t.incomeRub != null ? ' (${MoneyText.format(t.incomeRub!, 'RUB')})' : ''}',
),
if (t.outcomeAccountId != null) if (t.outcomeAccountId != null)
('Счёт списания', accountNames[t.outcomeAccountId] ?? '#${t.outcomeAccountId}'), (
'Счёт списания',
accountNames[t.outcomeAccountId] ?? '#${t.outcomeAccountId}',
),
if (t.incomeAccountId != null) if (t.incomeAccountId != null)
('Счёт зачисления', accountNames[t.incomeAccountId] ?? '#${t.incomeAccountId}'), (
'Счёт зачисления',
accountNames[t.incomeAccountId] ?? '#${t.incomeAccountId}',
),
if (t.mcc != null) ('MCC', '${t.mcc}'), if (t.mcc != null) ('MCC', '${t.mcc}'),
('Удержание (hold)', t.hold ? 'да' : 'нет'), ('Удержание (hold)', t.hold ? 'да' : 'нет'),
('Разовая трата', t.isOneOff ? 'да' : 'нет'), ('Разовая трата', t.isOneOff ? 'да' : 'нет'),
if (t.tags.isNotEmpty) ('Теги', t.tags.map((id) => categoryNames[id] ?? '#$id').join(', ')), if (t.tags.isNotEmpty)
('Теги', t.tags.map((id) => categoryNames[id] ?? '#$id').join(', ')),
if (t.tripId != null) ('Поездка', '#${t.tripId}'), if (t.tripId != null) ('Поездка', '#${t.tripId}'),
('Источник (id)', t.sourceId), ('Источник (id)', t.sourceId),
]; ];
@@ -334,7 +390,13 @@ class _TransactionDetailSheet extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox(width: 140, child: Text(label, style: Theme.of(context).textTheme.bodySmall)), SizedBox(
width: 140,
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
),
Expanded(child: Text(value)), Expanded(child: Text(value)),
], ],
), ),
+5 -1
View File
@@ -33,6 +33,10 @@ void main() {
test('non-date parameters are passed through untouched', () { test('non-date parameters are passed through untouched', () {
final out = run({'page': 2, 'q': 'кофе', 'include_deleted': false}); final out = run({'page': 2, 'q': 'кофе', 'include_deleted': false});
expect(out.queryParameters, {'page': 2, 'q': 'кофе', 'include_deleted': false}); expect(out.queryParameters, {
'page': 2,
'q': 'кофе',
'include_deleted': false,
});
}); });
} }
+27 -7
View File
@@ -28,6 +28,7 @@ EventOut event({
kind: kind, kind: kind,
price: '100', price: '100',
priceCurrency: currency, priceCurrency: currency,
source_: 'tinvest',
quantity: quantity, quantity: quantity,
status: status, status: status,
tax: null, tax: null,
@@ -44,7 +45,9 @@ Widget wrap(EventOut e) => MaterialApp(
); );
void main() { void main() {
testWidgets('shows kind, ticker, account and the native amount', (tester) async { testWidgets('shows kind, ticker, account and the native amount', (
tester,
) async {
await tester.pumpWidget(wrap(event())); await tester.pumpWidget(wrap(event()));
expect(find.text('Покупка · SBER'), findsOneWidget); expect(find.text('Покупка · SBER'), findsOneWidget);
@@ -53,15 +56,23 @@ void main() {
expect(find.text(MoneyText.format('-10000', 'RUB')), findsOneWidget); expect(find.text(MoneyText.format('-10000', 'RUB')), findsOneWidget);
}); });
testWidgets('adds the RUB equivalent only for a foreign currency', (tester) async { testWidgets('adds the RUB equivalent only for a foreign currency', (
await tester.pumpWidget(wrap(event(currency: 'USD', amount: '-100', amountRub: '-9500'))); tester,
) async {
await tester.pumpWidget(
wrap(event(currency: 'USD', amount: '-100', amountRub: '-9500')),
);
expect(find.text(MoneyText.format('-100', 'USD')), findsOneWidget); expect(find.text(MoneyText.format('-100', 'USD')), findsOneWidget);
expect(find.text(MoneyText.format('-9500', 'RUB')), findsOneWidget); expect(find.text(MoneyText.format('-9500', 'RUB')), findsOneWidget);
}); });
testWidgets('leaves the RUB line out when no rate was available', (tester) async { testWidgets('leaves the RUB line out when no rate was available', (
await tester.pumpWidget(wrap(event(currency: 'USD', amount: '-100', amountRub: null))); tester,
) async {
await tester.pumpWidget(
wrap(event(currency: 'USD', amount: '-100', amountRub: null)),
);
expect(find.text(MoneyText.format('-100', 'USD')), findsOneWidget); expect(find.text(MoneyText.format('-100', 'USD')), findsOneWidget);
expect(find.textContaining(''), findsNothing); expect(find.textContaining(''), findsNothing);
@@ -73,9 +84,18 @@ void main() {
expect(find.textContaining('Ожидает'), findsOneWidget); expect(find.textContaining('Ожидает'), findsOneWidget);
}); });
testWidgets('drops the separator for an event with no instrument', (tester) async { testWidgets('drops the separator for an event with no instrument', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
wrap(event(kind: EventKind.deposit, ticker: null, quantity: null, externalFlow: true)), wrap(
event(
kind: EventKind.deposit,
ticker: null,
quantity: null,
externalFlow: true,
),
),
); );
expect(find.text('Пополнение'), findsOneWidget); expect(find.text('Пополнение'), findsOneWidget);
+11 -4
View File
@@ -1,7 +1,8 @@
import 'package:fintracker_api/fintracker_api.dart'; import 'package:fintracker_api/fintracker_api.dart';
import 'package:fintracker_app/core/cache/cached.dart'; import 'package:fintracker_app/core/cache/cached.dart';
import 'package:fintracker_app/features/health/health_page.dart'; import 'package:fintracker_app/features/health/health_page.dart';
import 'package:fintracker_app/features/home/providers.dart' show dataQualityProvider; import 'package:fintracker_app/features/home/providers.dart'
show dataQualityProvider;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -26,7 +27,9 @@ DataQualityRow _issue() => DataQualityRow(
); );
void main() { void main() {
testWidgets('shows sources on the first tab and findings on the second', (tester) async { testWidgets('shows sources on the first tab and findings on the second', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
@@ -50,7 +53,9 @@ void main() {
expect(find.text('Курс не найден для 3 операций.'), findsOneWidget); expect(find.text('Курс не найден для 3 операций.'), findsOneWidget);
}); });
testWidgets('opens directly on the findings tab when initialTab is 1', (tester) async { testWidgets('opens directly on the findings tab when initialTab is 1', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
@@ -72,7 +77,9 @@ void main() {
overrides: [ overrides: [
syncStatusProvider.overrideWith((ref) async => const []), syncStatusProvider.overrideWith((ref) async => const []),
syncRunsProvider.overrideWith((ref) async => const []), syncRunsProvider.overrideWith((ref) async => const []),
dataQualityProvider.overrideWith((ref) => const Cached(<DataQualityRow>[])), dataQualityProvider.overrideWith(
(ref) => const Cached(<DataQualityRow>[]),
),
], ],
child: const MaterialApp(home: HealthPage(initialTab: 1)), child: const MaterialApp(home: HealthPage(initialTab: 1)),
), ),
+14 -5
View File
@@ -145,15 +145,19 @@ void main() {
final json = Map<String, dynamic>.from(_previewJson)..['account_id'] = null; final json = Map<String, dynamic>.from(_previewJson)..['account_id'] = null;
expect(ImportPreview.fromJson(json).canCommit, isFalse); expect(ImportPreview.fromJson(json).canCommit, isFalse);
final failed = Map<String, dynamic>.from(_previewJson)..['parse_status'] = 'failed'; final failed = Map<String, dynamic>.from(_previewJson)
..['parse_status'] = 'failed';
expect(ImportPreview.fromJson(failed).canCommit, isFalse); expect(ImportPreview.fromJson(failed).canCommit, isFalse);
expect(ImportPreview.fromJson(failed).canDelete, isTrue); expect(ImportPreview.fromJson(failed).canDelete, isTrue);
final committed = Map<String, dynamic>.from(_previewJson)..['parse_status'] = 'committed'; final committed = Map<String, dynamic>.from(_previewJson)
..['parse_status'] = 'committed';
expect(ImportPreview.fromJson(committed).canDelete, isFalse); expect(ImportPreview.fromJson(committed).canDelete, isFalse);
}); });
test('parses PendingResolveResult and builds the create body as plain strings', () { test(
'parses PendingResolveResult and builds the create body as plain strings',
() {
final r = PendingResolveResult.fromJson(const { final r = PendingResolveResult.fromJson(const {
'id': 4, 'id': 4,
'status': 'resolved', 'status': 'resolved',
@@ -174,7 +178,12 @@ void main() {
lot: 1, lot: 1,
).toJson(); ).toJson();
expect(body['asset_class'], 'index'); expect(body['asset_class'], 'index');
expect(body.containsKey('isin'), isFalse, reason: 'empty optionals are omitted'); expect(
body.containsKey('isin'),
isFalse,
reason: 'empty optionals are omitted',
);
expect(body['ticker'], 'IMOEX'); expect(body['ticker'], 'IMOEX');
}); },
);
} }
+64 -21
View File
@@ -33,7 +33,11 @@ void main() {
'tax_withheld': null, 'tax_withheld': null,
}, },
], ],
'by_basis': {'schedule': '8100.00', 'announced': '4130.50', 'history': '2000.00'}, 'by_basis': {
'schedule': '8100.00',
'announced': '4130.50',
'history': '2000.00',
},
}; };
test('parses the calendar, keeping every amount as a string', () { test('parses the calendar, keeping every amount as a string', () {
@@ -44,13 +48,20 @@ void main() {
expect(c.entries.single.basis, 'announced'); expect(c.entries.single.basis, 'announced');
expect(c.entries.single.ticker, 'SBER'); expect(c.entries.single.ticker, 'SBER');
expect(c.entries.single.taxWithheld, isNull); expect(c.entries.single.taxWithheld, isNull);
expect(c.entries.single.perUnit, '34.84', reason: 'never parsed through double'); expect(
c.entries.single.perUnit,
'34.84',
reason: 'never parsed through double',
);
}); });
test('a null amount_rub stays null — no FX rate is not zero', () { test('a null amount_rub stays null — no FX rate is not zero', () {
final json = Map<String, dynamic>.from(calendarJson); final json = Map<String, dynamic>.from(calendarJson);
json['entries'] = [ json['entries'] = [
{...(calendarJson['entries'] as List).first as Map<String, dynamic>, 'amount_rub': null}, {
...(calendarJson['entries'] as List).first as Map<String, dynamic>,
'amount_rub': null,
},
]; ];
expect(IncomeCalendar.fromJson(json).entries.single.amountRub, isNull); expect(IncomeCalendar.fromJson(json).entries.single.amountRub, isNull);
}); });
@@ -82,18 +93,28 @@ void main() {
{ {
'month': '2026-10-01', 'month': '2026-10-01',
'amount_rub': '1830.20', 'amount_rub': '1830.20',
'by_basis': {'schedule': '1133.40', 'announced': '696.80', 'history': '0'}, 'by_basis': {
'schedule': '1133.40',
'announced': '696.80',
'history': '0',
},
}, },
], ],
'total_rub': '21960.00', 'total_rub': '21960.00',
'annual_yield_on_value': '0.081', 'annual_yield_on_value': '0.081',
'warnings': ['у 3 инструментов нет истории выплат — в прогноз не вошли'], 'warnings': [
'у 3 инструментов нет истории выплат — в прогноз не вошли',
],
}); });
expect(f.months.single.byBasis['schedule'], '1133.40'); expect(f.months.single.byBasis['schedule'], '1133.40');
expect(f.totalRub, '21960.00'); expect(f.totalRub, '21960.00');
expect(f.annualYieldOnValue, '0.081'); expect(f.annualYieldOnValue, '0.081');
expect(f.warnings, hasLength(1)); expect(f.warnings, hasLength(1));
expect(f.bases, ['schedule', 'announced', 'history'], reason: 'contract order'); expect(f.bases, [
'schedule',
'announced',
'history',
], reason: 'contract order');
final noYield = IncomeForecast.fromJson(const { final noYield = IncomeForecast.fromJson(const {
'months': <dynamic>[], 'months': <dynamic>[],
@@ -110,7 +131,12 @@ void main() {
'dimension': 'asset_class', 'dimension': 'asset_class',
'weights_sum': '1.00', 'weights_sum': '1.00',
'targets': [ 'targets': [
{'bucket': 'share', 'target_weight': '0.60', 'band': '0.05', 'note': null}, {
'bucket': 'share',
'target_weight': '0.60',
'band': '0.05',
'note': null,
},
{'bucket': 'bond', 'target_weight': '0.30', 'band': '0.05'}, {'bucket': 'bond', 'target_weight': '0.30', 'band': '0.05'},
{'bucket': 'cash', 'target_weight': '0.10', 'band': '0.02'}, {'bucket': 'cash', 'target_weight': '0.10', 'band': '0.02'},
], ],
@@ -121,22 +147,29 @@ void main() {
expect(set.targets.first.band, '0.05'); expect(set.targets.first.band, '0.05');
// 0.1 + 0.2 + 0.7 is exactly 1 in Decimal and would not be in double // 0.1 + 0.2 + 0.7 is exactly 1 in Decimal and would not be in double
final tenths = TargetSet(dimension: 'asset_class', targets: const [ final tenths = TargetSet(
dimension: 'asset_class',
targets: const [
TargetWeight(bucket: 'a', targetWeight: '0.1'), TargetWeight(bucket: 'a', targetWeight: '0.1'),
TargetWeight(bucket: 'b', targetWeight: '0.2'), TargetWeight(bucket: 'b', targetWeight: '0.2'),
TargetWeight(bucket: 'c', targetWeight: '0.7'), TargetWeight(bucket: 'c', targetWeight: '0.7'),
]); ],
);
expect(tenths.sumIsValid, isTrue); expect(tenths.sumIsValid, isTrue);
final short = TargetSet(dimension: 'asset_class', targets: const [ final short = TargetSet(
TargetWeight(bucket: 'a', targetWeight: '0.9'), dimension: 'asset_class',
]); targets: const [TargetWeight(bucket: 'a', targetWeight: '0.9')],
);
expect(short.sumIsValid, isFalse); expect(short.sumIsValid, isFalse);
}); });
test('the PUT body omits empty optionals and keeps shares as strings', () { test('the PUT body omits empty optionals and keeps shares as strings', () {
final body = const TargetWeight(bucket: 'bond', targetWeight: '0.30', band: '0.05') final body = const TargetWeight(
.toJson(); bucket: 'bond',
targetWeight: '0.30',
band: '0.05',
).toJson();
expect(body['target_weight'], '0.30'); expect(body['target_weight'], '0.30');
expect(body.containsKey('note'), isFalse); expect(body.containsKey('note'), isFalse);
}); });
@@ -151,7 +184,9 @@ void main() {
expect(formatShareAsPercent(null), ''); expect(formatShareAsPercent(null), '');
}); });
test('parses the rebalance plan, including within_band and blocked_by_cash', () { test(
'parses the rebalance plan, including within_band and blocked_by_cash',
() {
final plan = RebalancePlan.fromJson(const { final plan = RebalancePlan.fromJson(const {
'portfolio_id': 1, 'portfolio_id': 1,
'dimension': 'asset_class', 'dimension': 'asset_class',
@@ -195,7 +230,8 @@ void main() {
expect(trade.lot, 10); expect(trade.lot, 10);
expect(trade.blockedByCash, isFalse); expect(trade.blockedByCash, isFalse);
expect(plan.warnings, hasLength(1)); expect(plan.warnings, hasLength(1));
}); },
);
test('suggested_qty stays null when there is no price', () { test('suggested_qty stays null when there is no price', () {
final trade = RebalanceTrade.fromJson(const { final trade = RebalanceTrade.fromJson(const {
@@ -214,7 +250,9 @@ void main() {
}); });
group('benchmarks', () { group('benchmarks', () {
test('parses a period row with excess, kind and days_skipped on both sides', () { test(
'parses a period row with excess, kind and days_skipped on both sides',
() {
final rows = [ final rows = [
for (final r in const [ for (final r in const [
{ {
@@ -253,10 +291,14 @@ void main() {
expect(row.portfolioDaysSkipped, 3); expect(row.portfolioDaysSkipped, 3);
expect(row.hasSkippedDays, isTrue); expect(row.hasSkippedDays, isTrue);
expect(row.benchmarks.first.isPriceIndex, isFalse); expect(row.benchmarks.first.isPriceIndex, isFalse);
expect(row.benchmarks.last.isPriceIndex, isTrue, expect(
reason: 'a price index must be markable'); row.benchmarks.last.isPriceIndex,
isTrue,
reason: 'a price index must be markable',
);
expect(row.benchmarks.first.excess, '0.063'); expect(row.benchmarks.first.excess, '0.063');
}); },
);
}); });
group('goals', () { group('goals', () {
@@ -346,7 +388,8 @@ void main() {
'dividends_gross_rub': '12400.00', 'dividends_gross_rub': '12400.00',
'estimated_tax_rub': '1963.00', 'estimated_tax_rub': '1963.00',
}, },
'disclaimer': 'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.', 'disclaimer':
'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.',
}); });
expect(s.year, 2026); expect(s.year, 2026);
+3 -1
View File
@@ -7,7 +7,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
testWidgets('the add-rule dialog requires a non-empty pattern', (tester) async { testWidgets('the add-rule dialog requires a non-empty pattern', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
+3 -1
View File
@@ -31,7 +31,9 @@ TransactionOut _usdExpense() => TransactionOut(
); );
void main() { void main() {
testWidgets('shows a USD amount with its RUB equivalent as secondary text', (tester) async { testWidgets('shows a USD amount with its RUB equivalent as secondary text', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
+4 -1
View File
@@ -3,7 +3,10 @@ import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
test('AuthState.signedIn reflects status', () { test('AuthState.signedIn reflects status', () {
expect(const AuthState(AuthStatus.signedIn, accessToken: 't').signedIn, isTrue); expect(
const AuthState(AuthStatus.signedIn, accessToken: 't').signedIn,
isTrue,
);
expect(const AuthState(AuthStatus.signedOut).signedIn, isFalse); expect(const AuthState(AuthStatus.signedOut).signedIn, isFalse);
}); });
} }