Files
fin-tracker/app/lib/features/portfolio/allocation_tab.dart
T
Dmitry 7b419f4188 feat(app): offline-кэш на остальных экранах — фаза 5
accounts, cashflow, categories, goals, income, portfolio (+instrument),
rebalance, tax, rules переведены на Cached<T> по контракту
docs/ai/offline-cache.md. Общие/второстепенные селекторы (scopesProvider,
categoriesListProvider и т.п.) оставлены как есть — не основной контент
экрана. sync_page.dart и settings_page.dart не тронуты (первый — заменён
health-page отдельно, второй — чистые действия без списка для баннера).
2026-09-19 14:00:13 +03:00

217 lines
7.3 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,
),
),
],
),
),
],
);
}
}