feat(app): экраны портфеля — позиции, аллокация и карточка инструмента

Позиции и аллокация сделаны вкладками одного экрана, а не двумя пунктами навигации:
они отвечают на две половины одного вопроса, и десятый пункт в bottom bar оставил бы
по сорок пикселей на подпись. Scope переключается один раз и сразу для всех трёх
экранов — три экрана с разными scope были бы ловушкой.

Позиция без цены показывается прочерком, никогда нулём: её стоимость не входит в
итоги выше, и 0 ₽ читался бы как «ничего не стоит» вместо «неизвестно». То же для
общей прибыли, когда в портфеле есть хоть одна неоценённая бумага.

Подписи ключей берутся по .value, а не по .name: генератор придумывает Dart-имя
(assetClass для asset_class), и словарь, ключёванный по .name, молча не совпадает
никогда.

Тесты пампят экран на высокой поверхности: вкладки это длинные списки, а ListView
строит только видимое, и на дефолтных 800x600 таблица позиций просто не существует.
This commit is contained in:
Dmitry
2026-09-18 14:21:06 +03:00
parent 28ff63bdfa
commit 1d7769ffbf
11 changed files with 1530 additions and 2 deletions
+56
View File
@@ -107,6 +107,7 @@ class _HomePageState extends ConsumerState<HomePage> {
final runway = ref.watch(runwayProvider);
final status = ref.watch(metricsStatusProvider);
final dataQuality = ref.watch(dataQualityProvider);
final portfolio = ref.watch(portfolioSummaryHomeProvider);
return Scaffold(
appBar: AppBar(
@@ -175,6 +176,12 @@ class _HomePageState extends ConsumerState<HomePage> {
],
),
const SizedBox(height: 12),
AsyncValueView(
value: portfolio,
onRetry: () => ref.invalidate(portfolioSummaryHomeProvider),
data: (s) => s == null ? const SizedBox.shrink() : _PortfolioTiles(summary: s),
),
const SizedBox(height: 12),
AsyncValueView(
value: runway,
onRetry: () => ref.invalidate(runwayProvider),
@@ -476,3 +483,52 @@ class _LegendDot extends StatelessWidget {
);
}
}
/// The investment row of the dashboard: value, profit and the two returns, each linking
/// through to Портфель. Absent entirely until a broker ledger exists.
class _PortfolioTiles extends StatelessWidget {
const _PortfolioTiles({required this.summary});
final SummaryOut summary;
@override
Widget build(BuildContext context) {
final yearly = summary.returns.where((r) => r.period == '1y').firstOrNull;
return InkWell(
onTap: () => context.go('/portfolio'),
borderRadius: BorderRadius.circular(12),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: [
_StatTile(
label: 'Портфель',
value: MoneyText(summary.totalRub, currency: 'RUB'),
),
_StatTile(
label: 'Прибыль',
value: summary.pnlTotalRub == null
// null, not zero: something in the portfolio has no price today
? const Text('')
: MoneyText(summary.pnlTotalRub!, currency: 'RUB'),
),
_StatTile(
label: 'XIRR, год',
value: Text(_percent(yearly?.xirr)),
),
_StatTile(
label: 'TWR, год',
value: Text(_percent(yearly?.twr)),
),
],
),
);
}
static String _percent(String? value) {
if (value == null) return '';
final pct = _d(value) * 100;
final sign = pct > 0 ? '+' : '';
return '$sign${pct.toStringAsFixed(2).replaceAll('.', ',')} %';
}
}
+13
View File
@@ -63,4 +63,17 @@ void invalidateHomeProviders(WidgetRef ref) {
ref.invalidate(runwayProvider);
ref.invalidate(metricsStatusProvider);
ref.invalidate(dataQualityProvider);
ref.invalidate(portfolioSummaryHomeProvider);
}
/// The investment side of the dashboard: one scope-wide summary, `all` by default.
/// Null when the ledger is empty, which is the normal state before a broker sync.
final portfolioSummaryHomeProvider = FutureProvider.autoDispose<SummaryOut?>((ref) async {
try {
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary();
return r.data;
} on DioException catch (e) {
if (e.response?.statusCode == 404) return null;
rethrow;
}
});