feat: workspace switcher, per-project filters and run stamping (stage 1)
Some checks failed
Security / Security check (push) Failing after 2s
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>
This commit is contained in:
parent
7a38cd58aa
commit
2592a23cc0
14 changed files with 554 additions and 9 deletions
163
lib/widgets/chain_workspace_switcher.dart
Normal file
163
lib/widgets/chain_workspace_switcher.dart
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue