Some checks failed
Security / Security check (push) Failing after 2s
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>
163 lines
5.4 KiB
Dart
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);
|
|
}
|
|
}
|