Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
233 lines
7.5 KiB
Dart
233 lines
7.5 KiB
Dart
import 'package:decimal/decimal.dart';
|
|
import 'package:fintracker_api/fintracker_api.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/theme/chart_colors.dart';
|
|
import '../../core/widgets/async_value_view.dart';
|
|
import '../../core/widgets/empty_state.dart';
|
|
import '../../core/widgets/money_text.dart';
|
|
import 'labels.dart';
|
|
import 'providers.dart';
|
|
|
|
double _d(String s) => Decimal.parse(s).toDouble();
|
|
|
|
/// Fixed categorical order — slot colours assigned by position in the sorted buckets, never
|
|
/// by rank across dimensions, so a bucket keeps its colour as the portfolio moves.
|
|
const _palette = [
|
|
ChartColors.slot1Blue,
|
|
ChartColors.slot2Orange,
|
|
ChartColors.slot3Aqua,
|
|
ChartColors.slot4Yellow,
|
|
ChartColors.slot5Magenta,
|
|
];
|
|
|
|
/// Аллокация: one donut per dimension, each over the same total (securities plus cash).
|
|
class AllocationTab extends ConsumerWidget {
|
|
const AllocationTab({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final allocation = ref.watch(allocationProvider);
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: () async => ref.invalidate(allocationProvider),
|
|
child: AsyncValueView(
|
|
value: allocation,
|
|
onRetry: () => ref.invalidate(allocationProvider),
|
|
data: (cached) {
|
|
final rows = cached.data;
|
|
if (rows.isEmpty) {
|
|
return ListView(
|
|
children: const [
|
|
EmptyState(
|
|
icon: Icons.donut_large_outlined,
|
|
message: 'Аллокации ещё нет — нужен пересчёт метрик.',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
final byDimension = <AllocationDimension, List<AllocationBucket>>{};
|
|
for (final row in rows) {
|
|
byDimension.putIfAbsent(row.dimension, () => []).add(row);
|
|
}
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
for (final entry in byDimension.entries) ...[
|
|
_DimensionCard(dimension: entry.key, buckets: entry.value),
|
|
const SizedBox(height: 16),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DimensionCard extends StatelessWidget {
|
|
const _DimensionCard({required this.dimension, required this.buckets});
|
|
|
|
final AllocationDimension dimension;
|
|
final List<AllocationBucket> buckets;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final onlyUnknown = buckets.every(
|
|
(b) => b.bucket == 'unknown' || b.bucket == 'cash',
|
|
);
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
dimensionLabel(dimension),
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
if (onlyUnknown) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Атрибут не заполнен у инструментов — разрез пустой, а не нулевой.',
|
|
style: Theme.of(context).textTheme.bodySmall
|
|
?.copyWith(color: Theme.of(context).hintColor),
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final donut = SizedBox(
|
|
height: 200,
|
|
child: _Donut(dimension: dimension, buckets: buckets),
|
|
);
|
|
final legend = _Legend(dimension: dimension, buckets: buckets);
|
|
if (constraints.maxWidth >= 640) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
SizedBox(width: 220, child: donut),
|
|
const SizedBox(width: 24),
|
|
Expanded(child: legend),
|
|
],
|
|
);
|
|
}
|
|
return Column(
|
|
children: [donut, const SizedBox(height: 12), legend],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Donut extends StatelessWidget {
|
|
const _Donut({required this.dimension, required this.buckets});
|
|
|
|
final AllocationDimension dimension;
|
|
final List<AllocationBucket> buckets;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// a negative bucket (a short, an overdrawn balance) has no slice: a pie cannot draw one,
|
|
// and the legend still lists it with its real value
|
|
final positive = buckets.where((b) => _d(b.valueRub) > 0).toList();
|
|
if (positive.isEmpty) {
|
|
return const EmptyState(
|
|
icon: Icons.donut_large_outlined,
|
|
message: 'Нечего показать.',
|
|
);
|
|
}
|
|
return PieChart(
|
|
PieChartData(
|
|
sectionsSpace: 2,
|
|
centerSpaceRadius: 48,
|
|
sections: [
|
|
for (var i = 0; i < positive.length; i++)
|
|
PieChartSectionData(
|
|
value: _d(positive[i].valueRub),
|
|
color: _palette[i % _palette.length],
|
|
title: _d(positive[i].weight) >= 0.06
|
|
? formatPercent(positive[i].weight, signed: false)
|
|
: '',
|
|
titleStyle: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
),
|
|
radius: 52,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Legend extends StatelessWidget {
|
|
const _Legend({required this.dimension, required this.buckets});
|
|
|
|
final AllocationDimension dimension;
|
|
final List<AllocationBucket> buckets;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Column(
|
|
children: [
|
|
for (var i = 0; i < buckets.length; i++)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 10,
|
|
height: 10,
|
|
decoration: BoxDecoration(
|
|
color: _palette[i % _palette.length],
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
bucketLabel(dimension, buckets[i].bucket),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
if (buckets[i].holdingCount > 0)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: Text(
|
|
'${buckets[i].holdingCount}',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.hintColor,
|
|
),
|
|
),
|
|
),
|
|
MoneyText(
|
|
buckets[i].valueRub,
|
|
currency: 'RUB',
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(width: 12),
|
|
SizedBox(
|
|
width: 56,
|
|
child: Text(
|
|
formatPercent(buckets[i].weight, signed: false),
|
|
textAlign: TextAlign.right,
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|