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