Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
767 lines
25 KiB
Dart
767 lines
25 KiB
Dart
import 'package:fintracker_api/fintracker_api.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
import '../../core/cache/cached.dart';
|
||
import '../../core/widgets/async_value_view.dart';
|
||
import '../../core/widgets/empty_state.dart';
|
||
import '../../core/widgets/help_tip.dart';
|
||
import '../../core/widgets/money_text.dart';
|
||
import '../../core/widgets/stale_banner.dart';
|
||
import 'account_create_dialog.dart';
|
||
import 'actions.dart';
|
||
import 'providers.dart';
|
||
|
||
const _roleOrder = [
|
||
AccountRole.liquid,
|
||
AccountRole.savings,
|
||
AccountRole.investment,
|
||
AccountRole.debt,
|
||
];
|
||
|
||
String _roleLabel(AccountRole role) => switch (role) {
|
||
AccountRole.liquid => 'Ликвидные',
|
||
AccountRole.savings => 'Сбережения',
|
||
AccountRole.investment => 'Инвестиции',
|
||
AccountRole.debt => 'Долги',
|
||
AccountRole.unknownDefaultOpenApi => 'Неизвестно',
|
||
};
|
||
|
||
String accountKindLabel(AccountKind kind) => switch (kind) {
|
||
AccountKind.zmCash => 'Наличные',
|
||
AccountKind.zmCard => 'Карта',
|
||
AccountKind.zmChecking => 'Расчётный счёт',
|
||
AccountKind.zmDeposit => 'Вклад',
|
||
AccountKind.zmLoan => 'Кредит',
|
||
AccountKind.zmEmoney => 'Электронные деньги',
|
||
AccountKind.zmDebt => 'Долг',
|
||
AccountKind.broker => 'Брокерский счёт',
|
||
AccountKind.manualAsset => 'Актив вручную',
|
||
AccountKind.unknownDefaultOpenApi => 'Неизвестно',
|
||
};
|
||
|
||
/// Where an account comes from — the thing that tells a ZenMoney mirror from the broker
|
||
/// account it mirrors when both are called «ИИС».
|
||
String _sourceLabel(String source) => switch (source) {
|
||
'zenmoney' => 'ZenMoney',
|
||
'tinvest' => 'T-Invest',
|
||
'report_sber' => 'Сбер, отчёты',
|
||
'report_vtb' => 'ВТБ, отчёты',
|
||
'csv' => 'CSV',
|
||
_ => source,
|
||
};
|
||
|
||
enum _Status {
|
||
all('Все'),
|
||
active('Активные'),
|
||
disabled('Отключённые'),
|
||
archived('Архивные');
|
||
|
||
const _Status(this.label);
|
||
final String label;
|
||
|
||
bool matches(AccountOut a) => switch (this) {
|
||
_Status.all => true,
|
||
_Status.active => !a.archived && !a.disabled,
|
||
_Status.disabled => a.disabled,
|
||
_Status.archived => a.archived && !a.disabled,
|
||
};
|
||
}
|
||
|
||
/// Below this width a row is two lines instead of one.
|
||
const _wideRow = 880.0;
|
||
|
||
/// Счета: every account in one compact list. The two switches that matter — «Активен» and
|
||
/// «В капитал» — and the account type sit on the row itself, and a selection turns them into
|
||
/// bulk actions: switching off a dozen old cards is one selection, not a dozen taps.
|
||
class AccountsPage extends ConsumerStatefulWidget {
|
||
const AccountsPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<AccountsPage> createState() => _AccountsPageState();
|
||
}
|
||
|
||
class _AccountsPageState extends ConsumerState<AccountsPage> {
|
||
_Status _status = _Status.all;
|
||
AccountRole? _role;
|
||
String _query = '';
|
||
final Set<int> _selected = {};
|
||
final Set<int> _busy = {};
|
||
|
||
void _snack(String message) =>
|
||
ScaffoldMessenger.of(context)
|
||
.showSnackBar(SnackBar(content: Text(message)));
|
||
|
||
Future<void> _apply(
|
||
Iterable<int> ids,
|
||
AccountPatch patch, {
|
||
bool clearSelection = false,
|
||
}) async {
|
||
final list = ids.toList();
|
||
setState(() => _busy.addAll(list));
|
||
final error = await ref.read(accountActionsProvider).patch(list, patch);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_busy.removeAll(list);
|
||
if (clearSelection) _selected.clear();
|
||
});
|
||
if (error != null) _snack(error);
|
||
}
|
||
|
||
List<AccountOut> _visible(List<AccountOut> rows) {
|
||
final q = _query.trim().toLowerCase();
|
||
int rank(AccountOut a) => a.archived ? 2 : (a.disabled ? 1 : 0);
|
||
final out =
|
||
[
|
||
for (final a in rows)
|
||
if (_status.matches(a) &&
|
||
(_role == null || a.role == _role) &&
|
||
(q.isEmpty ||
|
||
a.name.toLowerCase().contains(q) ||
|
||
accountKindLabel(a.kind).toLowerCase().contains(q) ||
|
||
_sourceLabel(a.source_).toLowerCase().contains(q)))
|
||
a,
|
||
]..sort((a, b) {
|
||
final byRank = rank(a).compareTo(rank(b));
|
||
if (byRank != 0) return byRank;
|
||
final byRole = _roleOrder
|
||
.indexOf(a.role)
|
||
.compareTo(_roleOrder.indexOf(b.role));
|
||
return byRole != 0 ? byRole : a.name.compareTo(b.name);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final accounts = ref.watch(accountsProvider);
|
||
final stale = oldestFetch([accounts.valueOrNull?.fetchedAt]);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('Счета'),
|
||
actions: [
|
||
IconButton(
|
||
tooltip: 'Новый брокерский счёт',
|
||
icon: const Icon(Icons.add),
|
||
onPressed: () => showAccountCreateDialog(context),
|
||
),
|
||
IconButton(
|
||
tooltip: 'Портфели',
|
||
icon: const Icon(Icons.pie_chart_outline),
|
||
onPressed: () => context.go('/portfolios'),
|
||
),
|
||
],
|
||
),
|
||
body: RefreshIndicator(
|
||
onRefresh: () async => ref.invalidate(accountsProvider),
|
||
child: AsyncValueView(
|
||
value: accounts,
|
||
onRetry: () => ref.invalidate(accountsProvider),
|
||
data: (cached) {
|
||
final rows = cached.data;
|
||
if (rows.isEmpty) {
|
||
return ListView(
|
||
children: const [
|
||
EmptyState(
|
||
icon: Icons.account_balance_outlined,
|
||
message: 'Счетов ещё нет — нужна синхронизация.',
|
||
),
|
||
],
|
||
);
|
||
}
|
||
// a selection can outlive its account (a sync archived it, a filter hid it)
|
||
_selected.retainAll({for (final a in rows) a.id});
|
||
final visible = _visible(rows);
|
||
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final wide = constraints.maxWidth >= _wideRow;
|
||
return ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
if (stale != null) StaleBanner(fetchedAt: stale),
|
||
_Filters(
|
||
rows: rows,
|
||
status: _status,
|
||
role: _role,
|
||
onStatus: (s) => setState(() => _status = s),
|
||
onRole: (r) => setState(() => _role = r),
|
||
onQuery: (q) => setState(() => _query = q),
|
||
),
|
||
const SizedBox(height: 12),
|
||
if (_selected.isNotEmpty) ...[
|
||
_BulkBar(
|
||
count: _selected.length,
|
||
busy: _busy.isNotEmpty,
|
||
onEnable: () => _apply(
|
||
_selected,
|
||
AccountPatch(disabled: false),
|
||
clearSelection: true,
|
||
),
|
||
onDisable: () => _apply(
|
||
_selected,
|
||
AccountPatch(disabled: true),
|
||
clearSelection: true,
|
||
),
|
||
onRole: (r) => _apply(
|
||
_selected,
|
||
AccountPatch(role: r),
|
||
clearSelection: true,
|
||
),
|
||
onNetWorth: (v) => _apply(
|
||
_selected,
|
||
AccountPatch(includeInNetWorth: v),
|
||
clearSelection: true,
|
||
),
|
||
onClear: () => setState(_selected.clear),
|
||
),
|
||
const SizedBox(height: 12),
|
||
],
|
||
Card(
|
||
child: visible.isEmpty
|
||
? const Padding(
|
||
padding: EdgeInsets.all(32),
|
||
child: Center(child: Text('Ничего не найдено')),
|
||
)
|
||
: Column(
|
||
children: [
|
||
if (wide)
|
||
_HeaderRow(
|
||
allSelected: visible.every(
|
||
(a) => _selected.contains(a.id),
|
||
),
|
||
onToggleAll: (on) => setState(() {
|
||
if (on) {
|
||
_selected.addAll(
|
||
visible.map((a) => a.id),
|
||
);
|
||
} else {
|
||
_selected.removeAll(
|
||
visible.map((a) => a.id),
|
||
);
|
||
}
|
||
}),
|
||
),
|
||
for (final a in visible)
|
||
_AccountRow(
|
||
account: a,
|
||
wide: wide,
|
||
selected: _selected.contains(a.id),
|
||
busy: _busy.contains(a.id),
|
||
onSelect: (on) => setState(() {
|
||
if (on) {
|
||
_selected.add(a.id);
|
||
} else {
|
||
_selected.remove(a.id);
|
||
}
|
||
}),
|
||
onRole: (r) =>
|
||
_apply([a.id], AccountPatch(role: r)),
|
||
onNetWorth: (v) => _apply([
|
||
a.id,
|
||
], AccountPatch(includeInNetWorth: v)),
|
||
onActive: (v) => _apply([
|
||
a.id,
|
||
], AccountPatch(disabled: !v)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Filters extends StatelessWidget {
|
||
const _Filters({
|
||
required this.rows,
|
||
required this.status,
|
||
required this.role,
|
||
required this.onStatus,
|
||
required this.onRole,
|
||
required this.onQuery,
|
||
});
|
||
|
||
final List<AccountOut> rows;
|
||
final _Status status;
|
||
final AccountRole? role;
|
||
final ValueChanged<_Status> onStatus;
|
||
final ValueChanged<AccountRole?> onRole;
|
||
final ValueChanged<String> onQuery;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
SizedBox(
|
||
width: 260,
|
||
child: TextField(
|
||
onChanged: onQuery,
|
||
decoration: const InputDecoration(
|
||
hintText: 'Найти счёт…',
|
||
isDense: true,
|
||
prefixIcon: Icon(Icons.search, size: 20),
|
||
),
|
||
),
|
||
),
|
||
for (final s in _Status.values)
|
||
ChoiceChip(
|
||
label: Text('${s.label} ${rows.where(s.matches).length}'),
|
||
selected: s == status,
|
||
onSelected: (_) => onStatus(s),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: [
|
||
for (final r in _roleOrder)
|
||
FilterChip(
|
||
label: Text(_roleLabel(r)),
|
||
selected: role == r,
|
||
onSelected: (on) => onRole(on ? r : null),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// «Выбрано N» with what can be done to all of them at once.
|
||
class _BulkBar extends StatelessWidget {
|
||
const _BulkBar({
|
||
required this.count,
|
||
required this.busy,
|
||
required this.onEnable,
|
||
required this.onDisable,
|
||
required this.onRole,
|
||
required this.onNetWorth,
|
||
required this.onClear,
|
||
});
|
||
|
||
final int count;
|
||
final bool busy;
|
||
final VoidCallback onEnable;
|
||
final VoidCallback onDisable;
|
||
final ValueChanged<AccountRole> onRole;
|
||
final ValueChanged<bool> onNetWorth;
|
||
final VoidCallback onClear;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final scheme = Theme.of(context).colorScheme;
|
||
return Card(
|
||
color: scheme.primary.withValues(alpha: 0.14),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
child: Wrap(
|
||
spacing: 8,
|
||
runSpacing: 4,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
Text(
|
||
'Выбрано: $count',
|
||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||
),
|
||
const SizedBox(width: 8),
|
||
TextButton.icon(
|
||
onPressed: busy ? null : onDisable,
|
||
icon: const Icon(Icons.toggle_off_outlined),
|
||
label: const Text('Отключить'),
|
||
),
|
||
TextButton.icon(
|
||
onPressed: busy ? null : onEnable,
|
||
icon: const Icon(Icons.toggle_on_outlined),
|
||
label: const Text('Включить'),
|
||
),
|
||
PopupMenuButton<AccountRole>(
|
||
enabled: !busy,
|
||
tooltip: 'Сменить тип',
|
||
onSelected: onRole,
|
||
itemBuilder: (_) => [
|
||
for (final r in _roleOrder)
|
||
PopupMenuItem(value: r, child: Text(_roleLabel(r))),
|
||
],
|
||
child: const _MenuLabel(icon: Icons.label_outline, text: 'Тип'),
|
||
),
|
||
PopupMenuButton<bool>(
|
||
enabled: !busy,
|
||
tooltip: 'Учёт в капитале',
|
||
onSelected: onNetWorth,
|
||
itemBuilder: (_) => const [
|
||
PopupMenuItem(value: true, child: Text('Учитывать в капитале')),
|
||
PopupMenuItem(
|
||
value: false,
|
||
child: Text('Не учитывать в капитале'),
|
||
),
|
||
],
|
||
child: const _MenuLabel(
|
||
icon: Icons.account_balance_wallet_outlined,
|
||
text: 'В капитал',
|
||
),
|
||
),
|
||
TextButton(onPressed: onClear, child: const Text('Снять выбор')),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _MenuLabel extends StatelessWidget {
|
||
const _MenuLabel({required this.icon, required this.text});
|
||
|
||
final IconData icon;
|
||
final String text;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final color = Theme.of(context).colorScheme.primary;
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 18, color: color),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
text,
|
||
style: TextStyle(color: color, fontWeight: FontWeight.w600),
|
||
),
|
||
Icon(Icons.arrow_drop_down, size: 18, color: color),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
const _typeWidth = 156.0;
|
||
const _amountWidth = 150.0;
|
||
const _switchWidth = 96.0;
|
||
|
||
class _HeaderRow extends StatelessWidget {
|
||
const _HeaderRow({required this.allSelected, required this.onToggleAll});
|
||
|
||
final bool allSelected;
|
||
final ValueChanged<bool> onToggleAll;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final scheme = Theme.of(context).colorScheme;
|
||
final style = Theme.of(context).textTheme.labelMedium
|
||
?.copyWith(color: scheme.onSurfaceVariant);
|
||
return Container(
|
||
padding: const EdgeInsets.fromLTRB(8, 4, 16, 4),
|
||
decoration: BoxDecoration(
|
||
border: Border(bottom: BorderSide(color: scheme.outlineVariant)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Checkbox(
|
||
value: allSelected,
|
||
onChanged: (v) => onToggleAll(v ?? false),
|
||
),
|
||
Expanded(child: Text('Счёт', style: style)),
|
||
SizedBox(
|
||
width: _typeWidth,
|
||
child: TermLabel('Тип', style: style),
|
||
),
|
||
SizedBox(
|
||
width: _amountWidth,
|
||
child: Text(
|
||
'Баланс / стоимость',
|
||
textAlign: TextAlign.end,
|
||
style: style,
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: _switchWidth,
|
||
child: TermLabel(
|
||
'В капитал',
|
||
style: style,
|
||
alignment: MainAxisAlignment.center,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: _switchWidth,
|
||
child: TermLabel(
|
||
'Активен',
|
||
style: style,
|
||
alignment: MainAxisAlignment.center,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _AccountRow extends StatelessWidget {
|
||
const _AccountRow({
|
||
required this.account,
|
||
required this.wide,
|
||
required this.selected,
|
||
required this.busy,
|
||
required this.onSelect,
|
||
required this.onRole,
|
||
required this.onNetWorth,
|
||
required this.onActive,
|
||
});
|
||
|
||
final AccountOut account;
|
||
final bool wide;
|
||
final bool selected;
|
||
final bool busy;
|
||
final ValueChanged<bool> onSelect;
|
||
final ValueChanged<AccountRole> onRole;
|
||
final ValueChanged<bool> onNetWorth;
|
||
final ValueChanged<bool> onActive;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final scheme = theme.colorScheme;
|
||
final a = account;
|
||
final isBroker = a.kind == AccountKind.broker;
|
||
// a broker account has no balance: what it is worth comes from the ledger valuation
|
||
final amount = isBroker ? a.valueRub : a.balance;
|
||
final dim = a.disabled || a.archived;
|
||
|
||
final title = Opacity(
|
||
opacity: dim ? 0.55 : 1,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
a.name,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||
),
|
||
Text(
|
||
[
|
||
accountKindLabel(a.kind),
|
||
a.currency,
|
||
_sourceLabel(a.source_),
|
||
if (a.archived) 'в архиве',
|
||
].join(' · '),
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: scheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
final money = Opacity(
|
||
opacity: dim ? 0.55 : 1,
|
||
child: amount == null
|
||
? Text('—', style: theme.textTheme.titleSmall)
|
||
: MoneyText(
|
||
amount,
|
||
currency: isBroker ? 'RUB' : a.currency,
|
||
style: theme.textTheme.titleSmall,
|
||
),
|
||
);
|
||
final role = _RoleChip(role: a.role, enabled: !busy, onChanged: onRole);
|
||
final netWorth = _SwitchCell(
|
||
label: 'В капитал',
|
||
value: a.includeInNetWorth,
|
||
enabled: !busy,
|
||
onChanged: onNetWorth,
|
||
showLabel: !wide,
|
||
);
|
||
final active = _SwitchCell(
|
||
label: 'Активен',
|
||
value: !a.disabled,
|
||
enabled: !busy,
|
||
onChanged: onActive,
|
||
showLabel: !wide,
|
||
);
|
||
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: selected ? scheme.primary.withValues(alpha: 0.08) : null,
|
||
border: Border(bottom: BorderSide(color: scheme.outlineVariant)),
|
||
),
|
||
child: InkWell(
|
||
onTap: () => onSelect(!selected),
|
||
child: Padding(
|
||
padding: EdgeInsets.fromLTRB(8, wide ? 8 : 6, 16, wide ? 8 : 6),
|
||
child: wide
|
||
? Row(
|
||
children: [
|
||
Checkbox(
|
||
value: selected,
|
||
onChanged: (v) => onSelect(v ?? false),
|
||
),
|
||
Expanded(child: title),
|
||
SizedBox(
|
||
width: _typeWidth,
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: role,
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: _amountWidth,
|
||
child: Align(
|
||
alignment: Alignment.centerRight,
|
||
child: money,
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: _switchWidth,
|
||
child: Center(child: netWorth),
|
||
),
|
||
SizedBox(
|
||
width: _switchWidth,
|
||
child: Center(child: active),
|
||
),
|
||
],
|
||
)
|
||
: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Checkbox(
|
||
value: selected,
|
||
visualDensity: VisualDensity.compact,
|
||
onChanged: (v) => onSelect(v ?? false),
|
||
),
|
||
Expanded(child: title),
|
||
money,
|
||
],
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.only(left: 40),
|
||
child: Wrap(
|
||
spacing: 8,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [role, netWorth, active],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The account type as a small pill that opens the four choices — one tap and one pick
|
||
/// instead of a form-field dropdown per row.
|
||
class _RoleChip extends StatelessWidget {
|
||
const _RoleChip({
|
||
required this.role,
|
||
required this.enabled,
|
||
required this.onChanged,
|
||
});
|
||
|
||
final AccountRole role;
|
||
final bool enabled;
|
||
final ValueChanged<AccountRole> onChanged;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final scheme = Theme.of(context).colorScheme;
|
||
return PopupMenuButton<AccountRole>(
|
||
enabled: enabled,
|
||
tooltip: 'Тип счёта',
|
||
onSelected: (r) {
|
||
if (r != role) onChanged(r);
|
||
},
|
||
itemBuilder: (_) => [
|
||
for (final r in _roleOrder)
|
||
CheckedPopupMenuItem(
|
||
value: r,
|
||
checked: r == role,
|
||
child: Text(_roleLabel(r)),
|
||
),
|
||
],
|
||
child: Container(
|
||
padding: const EdgeInsets.fromLTRB(12, 6, 6, 6),
|
||
decoration: BoxDecoration(
|
||
color: scheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Flexible(
|
||
child: Text(
|
||
_roleLabel(role),
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontSize: 13),
|
||
),
|
||
),
|
||
Icon(
|
||
Icons.arrow_drop_down,
|
||
size: 18,
|
||
color: scheme.onSurfaceVariant,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SwitchCell extends StatelessWidget {
|
||
const _SwitchCell({
|
||
required this.label,
|
||
required this.value,
|
||
required this.enabled,
|
||
required this.onChanged,
|
||
required this.showLabel,
|
||
});
|
||
|
||
final String label;
|
||
final bool value;
|
||
final bool enabled;
|
||
final ValueChanged<bool> onChanged;
|
||
final bool showLabel;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final sw = Semantics(
|
||
label: label,
|
||
child: Switch(value: value, onChanged: enabled ? onChanged : null),
|
||
);
|
||
if (!showLabel) return sw;
|
||
// on a phone both switches and the type pill have to share one line, so the switch is
|
||
// drawn at 80 % and keeps a tap area of its own
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||
SizedBox(
|
||
width: 40,
|
||
height: 32,
|
||
child: Transform.scale(scale: 0.75, child: sw),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|