feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события

Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
Dmitry
2026-09-19 22:14:21 +03:00
parent a559d6de3e
commit 62d36aa3e8
73 changed files with 6406 additions and 1192 deletions
+149
View File
@@ -0,0 +1,149 @@
/// Plain-language explanations of the terms and abbreviations the screens print, looked up by
/// the label as it appears. One place, so a term means the same on every screen and a wording
/// fix is a one-line change. `TermLabel` (`widgets/help_tip.dart`) calls [glossaryHint] and
/// draws a «?» next to any label that has an entry.
library;
const _entries = <String, String>{
// returns
'xirr':
'XIRR — годовая доходность с учётом дат и сумм ваших пополнений и выводов '
'(денежно-взвешенная): сколько вы заработали на своих деньгах в пересчёте на год. '
'На очень коротких сроках может быть недоступна или сильно завышена.',
'xirr, год':
'XIRR за последние 12 месяцев — годовая доходность с учётом дат и сумм ваших '
'пополнений и выводов.',
'twr':
'TWR — доходность самого портфеля без влияния пополнений и выводов: дневные '
'доходности перемножаются. Подходит, чтобы честно сравнивать с индексом.',
'twr, год':
'TWR за последние 12 месяцев — доходность портфеля без влияния пополнений и '
'выводов. «Пропущено N дн.» — дни, когда часть бумаг была без цены.',
'доходность':
'Доходность — XIRR: годовая доходность с учётом дат и сумм ваших пополнений '
'и выводов. «—» — если посчитать нечего или срок слишком короткий.',
'прибыль':
'Прибыль — на сколько текущая стоимость больше (или меньше) вложенного. По позиции: '
'рыночная стоимость минус стоимость покупки, процент — от вложенного. Пока у части бумаг '
'нет цены, итог показывается как «—».',
'нереализованная':
'Нереализованная прибыль — на сколько бумага подорожала или подешевела '
'относительно покупки, пока вы её не продали.',
'нереализ.':
'Нереализованная прибыль — на сколько бумага подорожала или подешевела '
'относительно покупки, пока вы её не продали.',
'реализованная':
'Реализованная прибыль — результат по уже проданным бумагам (правило FIFO: '
'первыми продаются самые ранние покупки).',
'реализовано':
'Реализованный результат по уже проданным бумагам (правило FIFO: первыми '
'продаются самые ранние покупки).',
'за день':
'За день — изменение результата за последний торговый день. Пополнения и выводы '
'в него не входят, только движение цен и выплаты.',
'потоки': 'Внешние потоки за период: пополнения за вычетом выводов.',
// value
'вложено':
'Вложено — стоимость покупки (для всего портфеля — пополнения минус выводы). '
'Ниже — средняя цена покупки за штуку.',
'текущая стоимость':
'Количество × последняя цена. Ниже — цена за одну бумагу. «—» значит, '
'что цены нет и стоимость неизвестна.',
'доля': 'Доля позиции в общей стоимости выбранного набора счетов.',
'доля в портфеле': 'Доля позиции в общей стоимости выбранного набора счетов.',
'выплаты': 'Полученные дивиденды и купоны за всё время.',
'дивиденды': 'Полученные по бумаге дивиденды и купоны за всё время.',
'див. доходность':
'Полученные выплаты в процентах от вложенного в позицию за всё время '
'владения.',
'пассивный доход':
'Пассивный доход — ожидаемые дивиденды и купоны за ближайшие 12 месяцев: '
'по объявленным выплатам, графику купонов облигаций и (для дивидендов) по истории за два '
'года. Это оценка, а не обещание. Процент — от текущей стоимости.',
'нкд':
'НКД — накопленный купонный доход: часть очередного купона, набежавшая с даты '
'последней выплаты. Покупатель облигации доплачивает его продавцу.',
// capital and cash
'капитал сегодня':
'Капитал — всё, чем вы владеете, за вычетом долгов: деньги и сбережения на '
'счетах ZenMoney плюс стоимость активных брокерских счетов.',
'ликвидные': 'Деньги, доступные сразу: карты, наличные, расчётные счета.',
'сбережения':
'Накопления, которые вы отложили: вклады и накопительные счета. Роль счёта '
'задаётся на экране «Счета».',
'инвестиции': 'Стоимость брокерских счетов: бумаги по рыночным ценам плюс деньги на счёте.',
'долги': 'Кредиты и долги; входят в капитал со знаком минус.',
'запас хода (runway)':
'Запас хода — на сколько месяцев хватит ликвидных денег и сбережений '
'при вашем обычном расходе: (ликвидные + сбережения) ÷ средний «обычный» расход за три '
'последних полных месяца, без разовых трат. Инвестиции в запас не входят.',
'запас хода':
'Запас хода — на сколько месяцев хватит ликвидных денег и сбережений при вашем '
'обычном расходе (средний «обычный» расход за три последних полных месяца).',
'норма сбережений':
'Норма сбережений — доля дохода, которая осталась после расходов: '
'(доход − расход) ÷ доход за месяц.',
// events
'внешний поток':
'Внешний поток — событие, при котором деньги или бумаги пересекают границу '
'портфеля: пополнение, вывод, перевод бумаг. XIRR считается именно по внешним потокам.',
'внешние потоки':
'Показать только события, при которых деньги или бумаги пересекают границу '
'портфеля: пополнения, выводы, переводы бумаг. По ним считается XIRR.',
'статус':
'Подтверждено — событие учитывается в расчётах. Теневое — дубль из второстепенного '
'источника, нужен только для сверки. Ожидает — загружено, но ещё не записано в леджер. '
'Игнорируется — исключено.',
// portfolio tools
'сравнение с бенчмарками':
'Бенчмарк — индекс для сравнения (например, индекс Мосбиржи): '
'показывает, обгоняет ли ваш портфель рынок за тот же период.',
'целевые веса':
'Целевые веса — какую долю портфеля вы хотите держать в каждой категории. '
'Сумма по одному измерению должна быть 100 %.',
// accounts
'тип':
'Тип счёта — его роль в расчётах: ликвидные, сбережения, инвестиции или долги. От неё '
'зависит, в какую строку «Капитала» попадёт остаток.',
'в капитал':
'Входит ли остаток счёта в «Капитал» на Обзоре. Выключите для счетов-зеркал '
'(например, ZenMoney-копия брокерского счёта): иначе одни и те же деньги посчитаются дважды.',
'активен':
'Отключённый счёт не учитывается ни в капитале, ни в портфелях, ни в аналитике; его '
'история остаётся и вернётся при включении.',
// tax
'лдв':
'ЛДВ — льгота долгосрочного владения (ст. 219.1 НК РФ): при владении биржевой бумагой '
'три полных года и более налог с прибыли по ней уменьшается. В приложении это оценка.',
'дата лдв': 'Дата, с которой лот пробудет в портфеле три полных года и попадёт под ЛДВ.',
'дней до лдв': 'Сколько дней осталось до трёх лет владения этим лотом (ЛДВ).',
'освобождено по лдв':
'Часть прибыли, освобождённая от налога по ЛДВ (три года владения '
'биржевой бумагой). Оценка.',
'оценка налога':
'Приблизительный НДФЛ по вашим сделкам — для сверки со справкой брокера. '
'Окончательный расчёт делает брокер как налоговый агент.',
'налог (оценка)':
'Приблизительный НДФЛ по вашим сделкам — для сверки со справкой брокера. '
'Окончательный расчёт делает брокер.',
'налог при продаже':
'Сколько налога примерно придётся заплатить, если продать этот лот '
'сегодня. Оценка.',
'налоговая база':
'Сумма, с которой считается налог; приложение оценивает её по вашим '
'сделкам. Окончательный расчёт делает брокер.',
'база': 'Сумма, с которой считается налог; приложение оценивает её по вашим сделкам.',
'удержано брокером':
'Налог, который брокер уже удержал при выплате дивидендов и купонов.',
'удержано':
'Налог, который брокер уже удержал при выплате дивидендов и купонов.',
'убыток':
'Отрицательный результат по проданным бумагам; уменьшает налоговую базу.',
};
String _key(String label) => label.trim().toLowerCase().replaceAll('ё', 'е');
final _normalized = {for (final e in _entries.entries) _key(e.key): e.value};
/// The explanation for [label], or null when it is not a term worth explaining.
String? glossaryHint(String label) => _normalized[_key(label)];
+121 -15
View File
@@ -1,25 +1,70 @@
import 'package:flutter/material.dart';
/// Design tokens for fin-tracker's visual language (`docs/ai/design-system.md`): dark
/// surfaces tinted blue-indigo rather than neutral grey, flat bordered cards instead of
/// Material's default elevation shadow, and one accent seed shared with
/// `ChartColors.slot1Blue` so UI chrome and the primary data series read as the same color.
/// Design tokens for fin-tracker's visual language (`docs/ai/design-system.md`): graphite-violet
/// surfaces, one bright sky-blue accent, flat rounded cards with no outline, filled inputs and
/// pill-shaped navigation — the look of Snowball.
///
/// Both brightnesses share this seed and this shape language — only the surface tones
/// Both brightnesses share this accent and this shape language — only the surface tones
/// differ — so the dark theme is not a special case bolted onto a default light Material
/// theme, and switching `themeModeProvider` never changes which widgets exist.
class AppTheme {
const AppTheme._();
/// The accent: buttons, active tabs, links and the primary chart series.
/// Matches `ChartColors.slot1Blue`.
static const seed = Color(0xFF2A78D6);
static const seed = Color(0xFF14AFFF);
static ThemeData light() => _build(Brightness.light);
static ThemeData dark() => _build(Brightness.dark);
static ColorScheme _scheme(Brightness brightness) {
final base = ColorScheme.fromSeed(seedColor: seed, brightness: brightness);
if (brightness == Brightness.light) {
return base.copyWith(
primary: const Color(0xFF0A98E6),
onPrimary: Colors.white,
surface: const Color(0xFFF2F2F7),
surfaceContainerLowest: Colors.white,
surfaceContainerLow: const Color(0xFFF7F7FB),
surfaceContainer: Colors.white,
surfaceContainerHigh: const Color(0xFFE8E8F0),
surfaceContainerHighest: const Color(0xFFDCDCE6),
outlineVariant: const Color(0xFFD8D8E2),
);
}
return base.copyWith(
primary: seed,
onPrimary: const Color(0xFF04283B),
primaryContainer: const Color(0xFF0E4B6E),
onPrimaryContainer: const Color(0xFFD3F0FF),
surface: const Color(0xFF25252F),
surfaceContainerLowest: const Color(0xFF1F1F28),
surfaceContainerLow: const Color(0xFF2A2A35),
surfaceContainer: const Color(0xFF2F2F3C),
surfaceContainerHigh: const Color(0xFF3A3A49),
surfaceContainerHighest: const Color(0xFF474757),
onSurface: const Color(0xFFEDEDF3),
onSurfaceVariant: const Color(0xFFB4B4C3),
outline: const Color(0xFF74748A),
outlineVariant: const Color(0xFF3F3F4F),
error: const Color(0xFFF26B6B),
);
}
static ThemeData _build(Brightness brightness) {
final scheme = ColorScheme.fromSeed(seedColor: seed, brightness: brightness);
final outline = scheme.outlineVariant.withValues(alpha: brightness == Brightness.dark ? 0.4 : 0.7);
final scheme = _scheme(brightness);
final dark = brightness == Brightness.dark;
final outline = scheme.outlineVariant;
final radius = BorderRadius.circular(12);
final fieldRadius = BorderRadius.circular(8);
OutlineInputBorder field([Color? color, double width = 1]) =>
OutlineInputBorder(
borderRadius: fieldRadius,
borderSide: color == null
? BorderSide.none
: BorderSide(color: color, width: width),
);
return ThemeData(
useMaterial3: true,
@@ -39,34 +84,95 @@ class AppTheme {
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: outline),
borderRadius: radius,
side: dark ? BorderSide.none : BorderSide(color: outline),
),
),
dividerTheme: DividerThemeData(color: outline, space: 1, thickness: 1),
dialogTheme: DialogThemeData(
backgroundColor: scheme.surfaceContainer,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: scheme.surfaceContainer,
surfaceTintColor: Colors.transparent,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surfaceContainerHigh,
border: field(),
enabledBorder: field(),
focusedBorder: field(scheme.primary, 1.5),
errorBorder: field(scheme.error),
focusedErrorBorder: field(scheme.error, 1.5),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: fieldRadius),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: fieldRadius),
side: BorderSide(color: outline),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: fieldRadius),
),
),
chipTheme: ChipThemeData(
backgroundColor: scheme.surfaceContainerHigh,
side: BorderSide.none,
shape: RoundedRectangleBorder(borderRadius: fieldRadius),
),
tabBarTheme: TabBarThemeData(
indicatorColor: scheme.primary,
labelColor: scheme.onSurface,
unselectedLabelColor: scheme.onSurfaceVariant,
dividerColor: outline,
labelStyle: const TextStyle(fontWeight: FontWeight.w600),
),
popupMenuTheme: PopupMenuThemeData(
color: scheme.surfaceContainerHigh,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: radius),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: scheme.surfaceContainerLow,
indicatorColor: scheme.primaryContainer,
useIndicator: true,
selectedIconTheme: IconThemeData(color: scheme.primary),
unselectedIconTheme: IconThemeData(color: scheme.onSurfaceVariant),
selectedLabelTextStyle: TextStyle(color: scheme.primary, fontWeight: FontWeight.w600),
selectedLabelTextStyle: TextStyle(
color: scheme.primary,
fontWeight: FontWeight.w600,
),
unselectedLabelTextStyle: TextStyle(color: scheme.onSurfaceVariant),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surfaceContainerLow,
backgroundColor: scheme.surfaceContainerLowest,
indicatorColor: scheme.primaryContainer,
elevation: 0,
iconTheme: WidgetStateProperty.resolveWith(
(states) => IconThemeData(
color: states.contains(WidgetState.selected) ? scheme.primary : scheme.onSurfaceVariant,
color: states.contains(WidgetState.selected)
? scheme.primary
: scheme.onSurfaceVariant,
),
),
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle(
fontSize: 12,
fontWeight: states.contains(WidgetState.selected) ? FontWeight.w600 : FontWeight.w400,
color: states.contains(WidgetState.selected) ? scheme.primary : scheme.onSurfaceVariant,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w600
: FontWeight.w400,
color: states.contains(WidgetState.selected)
? scheme.primary
: scheme.onSurfaceVariant,
),
),
),
+6 -1
View File
@@ -6,12 +6,17 @@ import 'package:flutter/material.dart';
class ChartColors {
const ChartColors._();
static const slot1Blue = Color(0xFF2A78D6);
static const slot1Blue = Color(0xFF14AFFF);
static const slot2Orange = Color(0xFFEB6834);
static const slot3Aqua = Color(0xFF1BAF7A);
static const slot4Yellow = Color(0xFFEDA100);
static const slot5Magenta = Color(0xFFE87BA4);
/// Gain and loss, everywhere a number is signed (`signColor`): mint and coral, readable on
/// both the graphite and the light surfaces.
static const gain = Color(0xFF3DDC97);
static const loss = Color(0xFFF26B6B);
/// This app's fixed assignment for cashflow charts.
static const income = slot1Blue;
static const expense = slot2Orange;
+3 -1
View File
@@ -4,7 +4,9 @@ import 'package:shared_preferences/shared_preferences.dart';
const _prefsKey = 'theme_mode';
final themeModeProvider = NotifierProvider<ThemeModeController, ThemeMode>(ThemeModeController.new);
final themeModeProvider = NotifierProvider<ThemeModeController, ThemeMode>(
ThemeModeController.new,
);
/// Persists the chosen [ThemeMode] to this device via `shared_preferences`.
class ThemeModeController extends Notifier<ThemeMode> {
+12 -10
View File
@@ -15,14 +15,15 @@ import 'package:dio/dio.dart';
import '../auth/auth_controller.dart' show problemMessage;
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) {
null => null,
final int i => i,
final String s => int.tryParse(s),
_ => null,
};
null => null,
final int i => i,
final String s => int.tryParse(s),
_ => null,
};
bool asBool(Object? v) => v == true;
@@ -53,7 +54,8 @@ List<Map<String, dynamic>> asObjects(Object? v) => v is List
? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
: const [];
Map<String, dynamic>? asObject(Object? v) => v is Map ? Map<String, dynamic>.from(v) : null;
Map<String, dynamic>? asObject(Object? v) =>
v is Map ? Map<String, dynamic>.from(v) : null;
List<String> asStrings(Object? v) =>
v is List ? v.map((e) => e.toString()).toList() : const <String>[];
@@ -74,9 +76,9 @@ Decimal? asDecimal(Object? v) {
/// Sum of decimal strings, exact — used for weight sums, where 0.1 + 0.2 in `double`
/// would make a valid set look invalid.
Decimal sumDecimals(Iterable<String> values) => values.fold(
Decimal.zero,
(acc, v) => acc + (Decimal.tryParse(v) ?? Decimal.zero),
);
Decimal.zero,
(acc, v) => acc + (Decimal.tryParse(v) ?? Decimal.zero),
);
/// RFC 7807 `detail` first, then a readable fallback. Never surfaces a raw [DioException].
String apiErrorMessage(Object error) {
+2 -1
View File
@@ -43,7 +43,8 @@ String ruMonthYear(DateTime d) {
String ruMonthYearShort(DateTime d) => '${_monthsShort[d.month - 1]} ${d.year}';
/// `'2026-09'`, the `month` query parameter format the API expects.
String monthKey(DateTime d) => '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}';
String monthKey(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}';
/// Parses a `'YYYY-MM'` key back into a [DateTime] (first of month, UTC).
DateTime parseMonthKey(String key) {
+104
View File
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
/// An asset's picture: the issuer's logo from the broker, and while it loads or when the
/// broker has none, a circle in the brand colour with the icon of the asset class.
///
/// The logo URL and colour come from the API as they are; a failed load (offline, a logo
/// the CDN has since dropped) quietly falls back rather than showing a broken image.
class AssetIcon extends StatelessWidget {
const AssetIcon({
super.key,
required this.assetClass,
this.logoUrl,
this.logoColor,
this.size = 32,
});
final String? assetClass;
final String? logoUrl;
final String? logoColor;
final double size;
@override
Widget build(BuildContext context) {
final url = logoUrl;
final fallback = _Fallback(
assetClass: assetClass,
logoColor: logoColor,
size: size,
);
return SizedBox.square(
dimension: size,
child: ClipOval(
child: url == null
? fallback
: Image.network(
url,
width: size,
height: size,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
gaplessPlayback: true,
errorBuilder: (_, _, _) => fallback,
loadingBuilder: (_, child, progress) =>
progress == null ? child : fallback,
),
),
);
}
}
class _Fallback extends StatelessWidget {
const _Fallback({
required this.assetClass,
required this.logoColor,
required this.size,
});
final String? assetClass;
final String? logoColor;
final double size;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final brand = parseBrandColor(logoColor);
final background = brand ?? scheme.surfaceContainerHigh;
final foreground = brand == null
? scheme.onSurfaceVariant
: (ThemeData.estimateBrightnessForColor(brand) == Brightness.dark
? Colors.white
: Colors.black87);
return ColoredBox(
color: background,
child: Icon(
assetClassIcon(assetClass),
size: size * 0.55,
color: foreground,
),
);
}
}
/// `#21A038` (or `#AA21A038`) as a colour; null for anything else, so a malformed value
/// from the broker degrades to the neutral fallback instead of throwing.
Color? parseBrandColor(String? hex) {
if (hex == null) return null;
final digits = hex.startsWith('#') ? hex.substring(1) : hex;
if (digits.length != 6 && digits.length != 8) return null;
final value = int.tryParse(digits, radix: 16);
if (value == null) return null;
return Color(digits.length == 6 ? 0xFF000000 | value : value);
}
IconData assetClassIcon(String? assetClass) => switch (assetClass) {
'share' => Icons.trending_up,
'bond' => Icons.receipt_long_outlined,
'etf' || 'fund' => Icons.pie_chart_outline,
'currency' => Icons.currency_exchange,
'index' => Icons.show_chart,
'deposit' => Icons.savings_outlined,
'real_estate' => Icons.home_outlined,
'crypto' => Icons.currency_bitcoin,
_ => Icons.circle_outlined,
};
+15 -3
View File
@@ -8,7 +8,12 @@ import '../auth/auth_controller.dart';
/// a retry-capable error view otherwise. Keeps the loading/error boilerplate
/// out of every screen that watches a provider.
class AsyncValueView<T> extends StatelessWidget {
const AsyncValueView({required this.value, required this.data, this.onRetry, super.key});
const AsyncValueView({
required this.value,
required this.data,
this.onRetry,
super.key,
});
final AsyncValue<T> value;
final Widget Function(T data) data;
@@ -30,12 +35,19 @@ class AsyncValueView<T> extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
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),
Text(_message(error), textAlign: TextAlign.center),
if (onRetry != null) ...[
const SizedBox(height: 12),
FilledButton.tonal(onPressed: onRetry, child: const Text('Повторить')),
FilledButton.tonal(
onPressed: onRetry,
child: const Text('Повторить'),
),
],
],
),
+5 -1
View File
@@ -2,7 +2,11 @@ import 'package:flutter/material.dart';
/// A centered placeholder for a screen or list section with nothing to show yet.
class EmptyState extends StatelessWidget {
const EmptyState({required this.message, this.icon = Icons.inbox_outlined, super.key});
const EmptyState({
required this.message,
this.icon = Icons.inbox_outlined,
super.key,
});
final String message;
final IconData icon;
+85
View File
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../glossary.dart';
/// A small «?» that explains itself: hover on desktop and web, tap on a phone. Deliberately
/// quiet — a muted outline icon, not a badge — because it sits next to text people read all day.
class HelpTip extends StatelessWidget {
const HelpTip(this.message, {this.size = 15, super.key});
final String message;
final double size;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: message,
triggerMode: TooltipTriggerMode.tap,
waitDuration: const Duration(milliseconds: 150),
showDuration: const Duration(seconds: 12),
constraints: const BoxConstraints(maxWidth: 340),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
margin: const EdgeInsets.all(12),
textStyle: const TextStyle(
fontSize: 13,
height: 1.35,
color: Colors.white,
),
decoration: BoxDecoration(
color: const Color(0xEB16161C),
borderRadius: BorderRadius.circular(8),
),
child: MouseRegion(
cursor: SystemMouseCursors.help,
child: Padding(
padding: const EdgeInsets.all(3),
child: Icon(
Icons.help_outline,
size: size,
color: scheme.onSurfaceVariant.withValues(alpha: 0.75),
),
),
),
);
}
}
/// A label that carries its own explanation: [text], then a [HelpTip] when the glossary knows the
/// term (or when [hint] is given, which wins — the same word can mean two things on two screens).
/// A label with no entry is a plain [Text], so it is safe to use for any caption.
class TermLabel extends StatelessWidget {
const TermLabel(
this.text, {
this.hint,
this.style,
this.alignment = MainAxisAlignment.start,
this.textAlign,
super.key,
});
final String text;
final String? hint;
final TextStyle? style;
/// Where the label sits in its box; numeric table headers pass [MainAxisAlignment.end].
final MainAxisAlignment alignment;
final TextAlign? textAlign;
@override
Widget build(BuildContext context) {
final message = hint ?? glossaryHint(text);
if (message == null) return Text(text, style: style, textAlign: textAlign);
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: alignment,
children: [
Flexible(
child: Text(text, style: style, textAlign: textAlign),
),
const SizedBox(width: 2),
HelpTip(message, size: (style?.fontSize ?? 14) + 1),
],
);
}
}
+6 -6
View File
@@ -4,11 +4,7 @@ import 'package:intl/intl.dart';
/// Currency symbols for the instruments we expect to see. Falls back to the
/// ISO code itself (e.g. 'USDT') when we don't have a nicer glyph.
const _currencySymbols = {
'RUB': '',
'USD': '\$',
'EUR': '',
};
const _currencySymbols = {'RUB': '', 'USD': '\$', 'EUR': ''};
/// Formats a decimal-string amount (as the API sends it, to avoid double
/// rounding errors) with `intl`'s ru_RU rules and a currency symbol.
@@ -23,7 +19,11 @@ class MoneyText extends StatelessWidget {
static String format(String amount, String currency) {
final value = Decimal.parse(amount).toDouble();
final symbol = _currencySymbols[currency] ?? currency;
final formatter = NumberFormat.currency(locale: 'ru_RU', symbol: symbol, decimalDigits: 2);
final formatter = NumberFormat.currency(
locale: 'ru_RU',
symbol: symbol,
decimalDigits: 2,
);
return formatter.format(value);
}
+3 -1
View File
@@ -23,7 +23,9 @@ class ScopeSelector extends ConsumerWidget {
value: scopes,
data: (rows) {
if (rows.length < 2) return const SizedBox.shrink();
final known = rows.any((s) => s.scope == current) ? current : rows.first.scope;
final known = rows.any((s) => s.scope == current)
? current
: rows.first.scope;
return DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: known,
+28 -7
View File
@@ -1,9 +1,17 @@
import 'package:flutter/material.dart';
import 'help_tip.dart';
/// A titled card section, the layout unit the phase-4 screens are built from (the same
/// shape Портфель already uses inline).
class SectionCard extends StatelessWidget {
const SectionCard({required this.title, required this.child, this.subtitle, this.trailing, super.key});
const SectionCard({
required this.title,
required this.child,
this.subtitle,
this.trailing,
super.key,
});
final String title;
final String? subtitle;
@@ -25,13 +33,15 @@ class SectionCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
TermLabel(title, style: theme.textTheme.titleMedium),
if (subtitle != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
),
],
@@ -51,7 +61,13 @@ class SectionCard extends StatelessWidget {
/// A compact labelled number, used for the summary rows of the phase-4 screens.
class StatTile extends StatelessWidget {
const StatTile({required this.label, required this.value, this.note, this.width = 184, super.key});
const StatTile({
required this.label,
required this.value,
this.note,
this.width = 184,
super.key,
});
final String label;
final Widget value;
@@ -69,14 +85,19 @@ class StatTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodySmall),
TermLabel(label, style: theme.textTheme.bodySmall),
const SizedBox(height: 4),
DefaultTextStyle(style: theme.textTheme.titleMedium!, child: value),
DefaultTextStyle(
style: theme.textTheme.titleMedium!,
child: value,
),
if (note != null) ...[
const SizedBox(height: 2),
Text(
note!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
maxLines: 3,
),
],
+3 -1
View File
@@ -23,7 +23,9 @@ class SectionHeader extends StatelessWidget {
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800),
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Container(
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// The service's mark: a sky-blue-to-violet disc with three rising bars. The same drawing as
/// `web/favicon.svg` (a 512 × 512 design scaled to [size]), painted natively so the top bar
/// needs neither an asset nor an SVG package.
class ServiceMark extends StatelessWidget {
const ServiceMark({this.size = 40, super.key});
final double size;
/// The far end of the gradient; the near end is the app accent, [AppTheme.seed].
static const violet = Color(0xFF7A5CFF);
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: size,
child: CustomPaint(painter: _MarkPainter()),
);
}
}
class _MarkPainter extends CustomPainter {
// (x, y, width, height) in the 512 design grid, tallest last
static const _bars = [
Rect.fromLTWH(132, 281, 64, 100),
Rect.fromLTWH(224, 211, 64, 170),
Rect.fromLTWH(316, 131, 64, 250),
];
@override
void paint(Canvas canvas, Size size) {
final s = size.width / 512;
final disc = Offset.zero & size;
canvas.drawCircle(
disc.center,
size.width / 2,
Paint()
..shader = const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppTheme.seed, ServiceMark.violet],
).createShader(disc),
);
final white = Paint()..color = Colors.white;
for (final bar in _bars) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(
bar.left * s,
bar.top * s,
bar.width * s,
bar.height * s,
),
Radius.circular(14 * s),
),
white,
);
}
}
@override
bool shouldRepaint(_MarkPainter oldDelegate) => false;
}
+6 -2
View File
@@ -14,11 +14,15 @@ class StaleBanner extends StatelessWidget {
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final at = fetchedAt.toLocal();
final time = '${at.hour.toString().padLeft(2, '0')}:${at.minute.toString().padLeft(2, '0')}';
final time =
'${at.hour.toString().padLeft(2, '0')}:${at.minute.toString().padLeft(2, '0')}';
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: scheme.errorContainer, borderRadius: BorderRadius.circular(8)),
decoration: BoxDecoration(
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.cloud_off, size: 18, color: scheme.onErrorContainer),