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: (rows) { if (rows.isEmpty) { return ListView( children: const [ EmptyState( icon: Icons.donut_large_outlined, message: 'Аллокации ещё нет — нужен пересчёт метрик.', ), ], ); } final byDimension = >{}; 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 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 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 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, ), ), ], ), ), ], ); } }