// 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/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'; /// 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 == null ? ws.activeSlug : _projectLabel(l, ws.active!))); 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( _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, ), ), ], ], ), ), ); } 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:` value. items.add( PopupMenuItem( enabled: false, padding: EdgeInsets.zero, child: SealedAreaSection( areas: ws.sealedAreas, namesVisible: WorkspacePrefs.sealedNamesVisible.value, ), ), ); } 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, ), ], ), ); } } /// 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); 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:')`, which hands the value to /// the enclosing PopupMenuButton exactly like a regular item. class SealedAreaSection extends StatefulWidget { final List areas; final bool namesVisible; const SealedAreaSection({ super.key, required this.areas, required this.namesVisible, }); @override State createState() => _SealedAreaSectionState(); } class _SealedAreaSectionState extends State { bool _revealed = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; if (!widget.namesVisible && !_revealed) { return 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, ), ], ), ), ], ), ), ); } 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), ), ), ), ], ), ), ), ], ); } }