The switcher used to be embedded per page (Flows/Runs/Audit/
Approvals) — invisible on the other five pages and sitting in a
different corner depending on the page (persona review 2026-08-27,
consensus finding). It now lives ONCE in the sidebar, above the
destinations: active project/area always visible, opens the same
menu everywhere, Cmd+P from anywhere. The shell listens to the
workspace, so the sidebar endpoint label can no longer lag a
sealed switch until the next health tick.
Also in this rebuild:
* Stopped sealed areas ask before starting ("Start area X?") —
a context switch must never boot a hub daemon as a click
side-effect; running areas keep switching with one click.
* The switcher tooltip told a wrong scope ("filters this view") —
it now says the choice applies everywhere and stamps new runs.
* The aggregated sealed row explains itself in place (names can
reveal client identities) and links to the Settings toggle
(Settings dialog gained an initialCategory jump).
* The active entry carries a checkmark in the menu.
* The Cmd+K palette knows projects and areas, ranked by recent
use; sealed names honour the privacy setting — while hidden,
the palette offers the guarded picker instead of the names.
* The runs empty state names the active project filter as the
cause ("No runs in project X" + show-all action) instead of
claiming the feature is off.
Tests updated to the anchor and made hermetic (scriptable
projects on the fake hub, sealed-area fake); new coverage for the
checkmark, the why-line, and the start confirmation.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
648 lines
22 KiB
Dart
648 lines
22 KiB
Dart
// Workspace switching — the ONE global control for "where am I
|
|
// working?".
|
|
//
|
|
// One control, two mechanics (docs/architecture/projects.md,
|
|
// § Studio): for open/protected projects the choice sets the page
|
|
// filter AND the label new runs are stamped with. "All projects"
|
|
// stays reachable — a filter, not a jail. Sealed areas are a real
|
|
// CONNECTION SWITCH: selecting one reconnects Studio to the area's
|
|
// own hub (starting it first if stopped, after an explicit
|
|
// confirmation) and colours the identity bar. Studio's blue stays
|
|
// the app accent — the project colour is marking, not theming.
|
|
//
|
|
// The control lives ONCE in the shell sidebar (ChainWorkspaceAnchor)
|
|
// so the active context is visible on every page — it used to be
|
|
// embedded per-page (Flows/Runs/Audit/Approvals), which left it
|
|
// invisible on the other five pages and moving between positions
|
|
// (persona review 2026-08-27).
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../data/hub.dart' show ProjectRef;
|
|
import '../data/sealed_areas.dart';
|
|
import '../data/workspace_prefs.dart';
|
|
import '../data/workspace.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../theme/tokens.dart';
|
|
import 'chain_settings_dialog.dart';
|
|
|
|
/// Menu-value scheme: '' = all projects, `p:<slug>` = shared project,
|
|
/// `s:<slug>` = sealed area, `settings:security` = the why-line's
|
|
/// jump to the Settings toggle.
|
|
const _kAll = '';
|
|
const _pProject = 'p:';
|
|
const _pSealed = 's:';
|
|
const _kSettingsSecurity = 'settings:security';
|
|
|
|
/// Menu values for external mount points (the command palette) —
|
|
/// the scheme itself stays private to this file.
|
|
String workspaceMenuValueAll() => _kAll;
|
|
String workspaceMenuValueProject(String slug) => '$_pProject$slug';
|
|
String workspaceMenuValueSealed(String slug) => '$_pSealed$slug';
|
|
|
|
/// The sidebar anchor — the single place the workspace switcher is
|
|
/// mounted. Geometry mirrors the sidebar's header rows: a fixed
|
|
/// icon column (context marking) + the label that fades in with the
|
|
/// rail expansion [t].
|
|
class ChainWorkspaceAnchor extends StatelessWidget {
|
|
/// Sidebar expansion 0..1 (collapsed → expanded).
|
|
final double t;
|
|
final bool labelsInteractive;
|
|
final double iconColumnWidth;
|
|
|
|
/// Suppress the sidebar's hover-collapse while the menu is open —
|
|
/// same contract as the channel pill (the menu would otherwise
|
|
/// float at the pill's old x).
|
|
final VoidCallback? onMenuOpen;
|
|
final VoidCallback? onMenuClose;
|
|
|
|
/// Lets the shell open the menu from the keyboard shortcut.
|
|
final GlobalKey<PopupMenuButtonState<String>>? menuKey;
|
|
|
|
const ChainWorkspaceAnchor({
|
|
super.key,
|
|
required this.t,
|
|
required this.labelsInteractive,
|
|
required this.iconColumnWidth,
|
|
this.onMenuOpen,
|
|
this.onMenuClose,
|
|
this.menuKey,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: Workspace.instance,
|
|
builder: (context, _) {
|
|
final l = AppLocalizations.of(context)!;
|
|
final theme = Theme.of(context);
|
|
final ws = Workspace.instance;
|
|
final label = workspaceContextLabel(l, ws);
|
|
|
|
final row = Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: iconColumnWidth,
|
|
child: Center(child: _ContextMark(ws: ws)),
|
|
),
|
|
Expanded(
|
|
child: t > 0
|
|
? IgnorePointer(
|
|
ignoring: !labelsInteractive,
|
|
child: Opacity(
|
|
opacity: t,
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
l.workspaceAnchorCaption,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
Text(
|
|
label,
|
|
style: theme.textTheme.bodyMedium,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Scales away mid-expansion — a fixed
|
|
// icon would overflow the row while the
|
|
// rail is still narrow.
|
|
Flexible(
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(
|
|
right: ChainSpace.md,
|
|
),
|
|
child: Icon(
|
|
Icons.unfold_more,
|
|
size: 16,
|
|
color:
|
|
theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
],
|
|
);
|
|
|
|
return Tooltip(
|
|
message: l.workspaceSwitcherTooltip,
|
|
waitDuration: const Duration(milliseconds: 400),
|
|
child: PopupMenuButton<String>(
|
|
key: menuKey,
|
|
tooltip: '', // the outer Tooltip carries the message
|
|
onOpened: () {
|
|
onMenuOpen?.call();
|
|
Workspace.instance.refresh();
|
|
},
|
|
onCanceled: () => onMenuClose?.call(),
|
|
onSelected: (v) {
|
|
onMenuClose?.call();
|
|
handleWorkspaceMenuSelection(context, v);
|
|
},
|
|
itemBuilder: (context) =>
|
|
workspaceMenuItems(context, ws: ws, l: l, theme: theme),
|
|
child: SizedBox(height: 44, child: row),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The collapsed-column marking for the active context: grid = all
|
|
/// projects, coloured dot = project, dot + lock = sealed area.
|
|
class _ContextMark extends StatelessWidget {
|
|
final Workspace ws;
|
|
|
|
const _ContextMark({required this.ws});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final sealed = ws.switching
|
|
? (ws.switchTarget ?? ws.activeSealed)
|
|
: ws.activeSealed;
|
|
if (sealed != null) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_ProjectDot(color: sealed.color),
|
|
const SizedBox(width: 4),
|
|
Icon(
|
|
Icons.lock_outline,
|
|
size: 13,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
if (ws.active != null) {
|
|
return _ProjectDot(color: ws.active!.color);
|
|
}
|
|
return Icon(
|
|
Icons.grid_view_outlined,
|
|
size: 16,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Display label for the active context (shared by anchor + tests).
|
|
String workspaceContextLabel(AppLocalizations l, Workspace ws) {
|
|
final sealed = ws.switching
|
|
? (ws.switchTarget ?? ws.activeSealed)
|
|
: ws.activeSealed;
|
|
if (sealed != null) return sealed.name;
|
|
if (ws.isAll) return l.workspaceAll;
|
|
final active = ws.active;
|
|
return active == null ? ws.activeSlug : _projectLabel(l, active);
|
|
}
|
|
|
|
/// The switcher menu, one source of truth for every mount point.
|
|
/// The active entry carries a checkmark so the menu answers "where
|
|
/// am I?" before anything is clicked.
|
|
List<PopupMenuEntry<String>> workspaceMenuItems(
|
|
BuildContext context, {
|
|
required Workspace ws,
|
|
required AppLocalizations l,
|
|
required ThemeData theme,
|
|
}) {
|
|
final inSealed = ws.inSealedArea;
|
|
final items = <PopupMenuEntry<String>>[
|
|
PopupMenuItem(
|
|
value: _kAll,
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.grid_view_outlined,
|
|
size: 16,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
Expanded(child: Text(l.workspaceAll)),
|
|
_ActiveCheck(active: !inSealed && ws.isAll),
|
|
],
|
|
),
|
|
),
|
|
];
|
|
if (ws.projects.isNotEmpty) items.add(const PopupMenuDivider());
|
|
for (final p in ws.projects) {
|
|
items.add(
|
|
PopupMenuItem(
|
|
value: '$_pProject${p.slug}',
|
|
child: Row(
|
|
children: [
|
|
_ProjectDot(color: p.color),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
Flexible(
|
|
child: Text(
|
|
_projectLabel(l, p),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
if (p.isProtected) ...[
|
|
const SizedBox(width: ChainSpace.sm),
|
|
Tooltip(
|
|
message: l.workspaceProtectedHint,
|
|
child: Icon(
|
|
Icons.shield_outlined,
|
|
size: 14,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
const Spacer(),
|
|
_ActiveCheck(active: !inSealed && ws.activeSlug == p.slug),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (ws.sealedAreas.isNotEmpty) {
|
|
items.add(const PopupMenuDivider());
|
|
items.add(
|
|
PopupMenuItem(
|
|
enabled: false,
|
|
height: 28,
|
|
child: Text(
|
|
l.workspaceSealedHeader,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
// Area names often carry client/mandate identity, so the
|
|
// section starts aggregated ("2 sealed areas") and reveals
|
|
// names only on a deliberate tap — unless the operator turned
|
|
// the direct listing back on in Settings -> Security
|
|
// (usertest security finding). The disabled PopupMenuItem is
|
|
// just the host; the section handles its own taps and pops
|
|
// the menu route with the regular `s:<slug>` value.
|
|
items.add(
|
|
PopupMenuItem<String>(
|
|
enabled: false,
|
|
padding: EdgeInsets.zero,
|
|
child: SealedAreaSection(
|
|
areas: ws.sealedAreas,
|
|
namesVisible: WorkspacePrefs.sealedNamesVisible.value,
|
|
activeSlug: ws.activeSealed?.slug,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return items;
|
|
}
|
|
|
|
/// Apply a switcher-menu selection. Shared by every mount point
|
|
/// (sidebar anchor, command palette). Starting a STOPPED sealed
|
|
/// area asks first — an area switch must never boot a hub daemon
|
|
/// as a click side-effect; a running area stays one click.
|
|
Future<void> handleWorkspaceMenuSelection(
|
|
BuildContext context,
|
|
String value,
|
|
) async {
|
|
final ws = Workspace.instance;
|
|
final l = AppLocalizations.of(context)!;
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
|
|
if (value == _kSettingsSecurity) {
|
|
await ChainSettingsDialog.show(context, initialCategory: 'security');
|
|
return;
|
|
}
|
|
if (value == _kAll) {
|
|
WorkspacePrefs.recordRecentContext(value);
|
|
await ws.setActive('');
|
|
return;
|
|
}
|
|
if (value.startsWith(_pProject)) {
|
|
WorkspacePrefs.recordRecentContext(value);
|
|
await ws.setActive(value.substring(_pProject.length));
|
|
return;
|
|
}
|
|
if (value.startsWith(_pSealed)) {
|
|
final slug = value.substring(_pSealed.length);
|
|
SealedArea? area;
|
|
for (final a in ws.sealedAreas) {
|
|
if (a.slug == slug) area = a;
|
|
}
|
|
if (area == null) return;
|
|
final target = area;
|
|
if (!target.running) {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(l.workspaceStartConfirmTitle(target.name)),
|
|
content: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
child: Text(l.workspaceStartConfirmBody),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: Text(l.buttonCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: Text(l.workspaceStartConfirmAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true) return;
|
|
// Announce the intent — starting the area's hub takes a beat.
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l.workspaceSealedStarting(target.name))),
|
|
);
|
|
}
|
|
WorkspacePrefs.recordRecentContext(value);
|
|
final r = await ws.switchToSealed(target);
|
|
messenger.hideCurrentSnackBar();
|
|
if (!r.ok) {
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: SelectableText(l.workspaceSealedSwitchFailed(r.error)),
|
|
duration: const Duration(seconds: 8),
|
|
),
|
|
);
|
|
} else if (r.started) {
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l.workspaceSealedStarted(target.name))),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Trailing checkmark slot — fixed width so rows with and without
|
|
/// the mark keep their text aligned.
|
|
class _ActiveCheck extends StatelessWidget {
|
|
final bool active;
|
|
|
|
const _ActiveCheck({required this.active});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return SizedBox(
|
|
width: 22,
|
|
child: active
|
|
? Icon(Icons.check, size: 16, color: theme.colorScheme.primary)
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Display name for a registry project. The hub's default project
|
|
/// carries the fixed English name "General"; render it localized
|
|
/// ("Allgemein") so no English label sits in the German menu.
|
|
String _projectLabel(AppLocalizations l, ProjectRef p) =>
|
|
p.slug == 'general' ? l.workspaceDefaultProject : p.name;
|
|
|
|
/// The project's registry colour as a small marking dot. Falls back
|
|
/// to the theme's outline colour when unset.
|
|
class _ProjectDot extends StatelessWidget {
|
|
final String color;
|
|
|
|
const _ProjectDot({required this.color});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Container(
|
|
width: 10,
|
|
height: 10,
|
|
decoration: BoxDecoration(
|
|
color: parseAreaColor(color) ?? theme.colorScheme.outline,
|
|
shape: BoxShape.circle,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Parse a `#rrggbb` hex colour, or null when unparseable.
|
|
Color? parseAreaColor(String hex) {
|
|
final h = hex.replaceFirst('#', '');
|
|
if (h.length != 6) return null;
|
|
final v = int.tryParse(h, radix: 16);
|
|
if (v == null) return null;
|
|
return Color(0xFF000000 | v);
|
|
}
|
|
|
|
/// The sealed-areas block of the switcher menu. Public so the
|
|
/// widget test can pump both privacy modes directly.
|
|
///
|
|
/// [namesVisible] = the operator's Settings choice. When false the
|
|
/// block renders one aggregated row (lock + count) with a why-line
|
|
/// (the aggregation is a deliberate confidentiality decision and
|
|
/// must explain itself in place) + a jump to the Settings toggle;
|
|
/// a deliberate tap expands the named rows for THIS menu opening
|
|
/// only — nothing is persisted from the reveal. Rows select via
|
|
/// `Navigator.pop(context, 's:<slug>')`, which hands the value to
|
|
/// the enclosing PopupMenuButton exactly like a regular item.
|
|
class SealedAreaSection extends StatefulWidget {
|
|
final List<SealedArea> areas;
|
|
final bool namesVisible;
|
|
|
|
/// Slug of the area Studio is connected to, or null — its row
|
|
/// carries the active checkmark.
|
|
final String? activeSlug;
|
|
|
|
const SealedAreaSection({
|
|
super.key,
|
|
required this.areas,
|
|
required this.namesVisible,
|
|
this.activeSlug,
|
|
});
|
|
|
|
@override
|
|
State<SealedAreaSection> createState() => _SealedAreaSectionState();
|
|
}
|
|
|
|
class _SealedAreaSectionState extends State<SealedAreaSection> {
|
|
bool _revealed = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
if (!widget.namesVisible && !_revealed) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
InkWell(
|
|
onTap: () => setState(() => _revealed = true),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 10,
|
|
),
|
|
// Two lines instead of one row: popup menus cap their
|
|
// width, and action texts must never be cut off
|
|
// (usertest finding class). Count on top, the reveal
|
|
// action fully readable beneath it.
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.lock_outline,
|
|
size: 13,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
Text(
|
|
l.workspaceSealedAggregate(widget.areas.length),
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 2),
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 21),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
l.workspaceSealedRevealAction,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.expand_more,
|
|
size: 14,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
// The why-line: aggregation is not a glitch but a
|
|
// confidentiality choice — say so where it happens, and
|
|
// point at the Settings toggle for machines where the
|
|
// listing is fine.
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(37, 0, 16, 6),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 240),
|
|
child: Text(
|
|
l.workspaceSealedAggregateWhy,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
InkWell(
|
|
onTap: () =>
|
|
Navigator.pop(context, _kSettingsSecurity),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Text(
|
|
l.workspaceSealedAggregateSettings,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
for (final a in widget.areas)
|
|
InkWell(
|
|
onTap: () => Navigator.pop(context, '$_pSealed${a.slug}'),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 10,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
_ProjectDot(color: a.color),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
Icon(
|
|
Icons.lock_outline,
|
|
size: 13,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: Text(
|
|
a.name,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
Tooltip(
|
|
message: a.running
|
|
? l.workspaceSealedRunningHint
|
|
: l.workspaceSealedStoppedHint,
|
|
child: Text(
|
|
a.running
|
|
? l.workspaceSealedRunning
|
|
: l.workspaceSealedStopped,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
// Secondary but readable — onSurfaceVariant
|
|
// fell below comfortable contrast at this
|
|
// size (usertest finding).
|
|
color: a.running
|
|
? ChainColors.success
|
|
: theme.colorScheme.onSurface
|
|
.withValues(alpha: 0.8),
|
|
),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
_ActiveCheck(active: a.slug == widget.activeSlug),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|