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
+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),