feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
@@ -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)];
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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('Повторить'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user