// ChainWorkspaceSwitcher — the AppBar workspace (project) control. // // 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, with a notice) and colours // the identity bar. Studio's blue stays the app accent — the project // colour is marking, not theming. import 'package:flutter/material.dart'; import '../data/sealed_areas.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; import '../theme/tokens.dart'; /// Menu-value scheme: '' = all projects, `p:` = shared project, /// `s:` = sealed area. const _kAll = ''; const _pProject = 'p:'; const _pSealed = 's:'; class ChainWorkspaceSwitcher extends StatelessWidget { const ChainWorkspaceSwitcher({super.key}); @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 = ws.inSealedArea ? ws.activeSealed!.name : (ws.isAll ? l.workspaceAll : (ws.active?.name ?? ws.activeSlug)); return Tooltip( message: l.workspaceSwitcherTooltip, child: PopupMenuButton( onOpened: ws.refresh, onSelected: (v) => _onSelected(context, v), itemBuilder: (context) => _items(context, ws, l, theme), child: _pill(context, ws, l, theme, label), ), ); }, ); } List> _items( BuildContext context, Workspace ws, AppLocalizations l, ThemeData theme, ) { final items = >[ PopupMenuItem( value: _kAll, child: Row( children: [ Icon( Icons.grid_view_outlined, size: 16, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: ChainSpace.sm), Text(l.workspaceAll), ], ), ), ]; 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(p.name, 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, ), ), ], ], ), ), ); } 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, ), ), ), ); for (final a in ws.sealedAreas) { items.add( PopupMenuItem( value: '$_pSealed${a.slug}', 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)), const SizedBox(width: ChainSpace.sm), Text( a.running ? l.workspaceSealedRunning : l.workspaceSealedStopped, style: theme.textTheme.labelSmall?.copyWith( color: a.running ? ChainColors.success : theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ); } } return items; } Future _onSelected(BuildContext context, String value) async { final ws = Workspace.instance; final l = AppLocalizations.of(context)!; final messenger = ScaffoldMessenger.of(context); if (value == _kAll) { await ws.setActive(''); return; } if (value.startsWith(_pProject)) { 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; // Announce the intent — starting a stopped area takes a beat. if (!area.running) { messenger.showSnackBar( SnackBar(content: Text(l.workspaceSealedStarting(area.name))), ); } final r = await ws.switchToSealed(area); 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(area.name))), ); } } } Widget _pill( BuildContext context, Workspace ws, AppLocalizations l, ThemeData theme, String label, ) { final Widget leading; if (ws.inSealedArea) { leading = Row( mainAxisSize: MainAxisSize.min, children: [ _ProjectDot(color: ws.activeSealed!.color), const SizedBox(width: 4), Icon( Icons.lock_outline, size: 13, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 4), ], ); } else if (ws.active != null) { leading = Padding( padding: const EdgeInsets.only(right: 6), child: _ProjectDot(color: ws.active!.color), ); } else { leading = Padding( padding: const EdgeInsets.only(right: 6), child: Icon( Icons.grid_view_outlined, size: 14, color: theme.colorScheme.onSurfaceVariant, ), ); } return Container( padding: const EdgeInsets.symmetric(horizontal: ChainSpace.md, vertical: 5), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(ChainRadius.sm), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ leading, ConstrainedBox( constraints: const BoxConstraints(maxWidth: 160), child: Text( label, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelMedium, ), ), if (!ws.inSealedArea && (ws.active?.isProtected ?? false)) ...[ const SizedBox(width: 4), Icon( Icons.shield_outlined, size: 13, color: theme.colorScheme.onSurfaceVariant, ), ], const SizedBox(width: 2), Icon( Icons.arrow_drop_down, size: 18, color: theme.colorScheme.onSurfaceVariant, ), ], ), ); } } /// 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); }