chain-studio/lib/widgets/chain_workspace_switcher.dart
flemming-it 2592a23cc0
Some checks failed
Security / Security check (push) Failing after 2s
feat: workspace switcher, per-project filters and run stamping (stage 1)
Multi-project stage 1 against the shared hub (platform design
docs/architecture/projects.md, § Studio):

- ChainWorkspaceSwitcher in the Audit + Approvals AppBars: lists the
  registry (colour dot per project, shield for protected, honesty
  tooltip), 'All projects' stays reachable — a filter, not a jail.
  Selection is persisted and shared via the Workspace notifier.
- Audit page: list query AND live stream re-scoped hub-side on switch.
- Approvals page: pending + history scoped; the sidebar badge counts
  the active workspace's pending approvals.
- Flow runs are stamped with the active workspace; a flow file
  carrying its own project: keeps it (file wins, CLI semantics).
- Data layer: listProjects/ProjectRef; project fields on AuditEvent,
  PendingApproval(+Record), SavedFlow; project params through
  HubService. l10n DE+EN. Widget tests for the switcher contract.

Visual verification (light+dark screenshots) still pending — the
shared desktop was in active use; code paths are covered by
flutter test (26 green).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-12 13:49:51 +02:00

163 lines
5.4 KiB
Dart

// 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 never
// appear here (own hub instance; stage-3 connection switch).
//
// The project colour is a marking dot only — Studio's blue stays
// the app accent (registered design deviation).
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
import '../theme/tokens.dart';
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 active = ws.active;
final label = ws.isAll
? l.workspaceAll
: (active?.name ?? ws.activeSlug);
return Tooltip(
message: l.workspaceSwitcherTooltip,
child: PopupMenuButton<String>(
onOpened: ws.refresh,
onSelected: ws.setActive,
itemBuilder: (context) => [
PopupMenuItem(
value: '',
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) const PopupMenuDivider(),
for (final p in ws.projects)
PopupMenuItem(
value: p.slug,
child: Row(
children: [
_ProjectDot(project: p),
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,
),
),
],
],
),
),
],
child: 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: [
if (active != null) ...[
_ProjectDot(project: active),
const SizedBox(width: 6),
] else ...[
Icon(
Icons.grid_view_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
],
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 160),
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium,
),
),
if (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 the project has none.
class _ProjectDot extends StatelessWidget {
final ProjectRef project;
const _ProjectDot({required this.project});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _parseHex(project.color) ?? theme.colorScheme.outline,
shape: BoxShape.circle,
),
);
}
static Color? _parseHex(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);
}
}