Some checks failed
Security / Security check (push) Failing after 1s
- Store hero: 'TODAY' badge only for an operator-accepted story; the rotating compiled-in fallback deck now says 'FEATURED' (no false freshness claim). Policy as a top-level function with unit tests. - Audit filter chips: standard label typography instead of mono — mono stays reserved for paths and identifiers. - DE chain wording: 'Hash-Kette geprüft' as the one confirmation term (audit header now matches the doctor pill); 'intakt' stays the state headline. EN was already consistent. - Workspace switcher: contrast bump for the sealed-area 'stopped' label. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
323 lines
9.7 KiB
Dart
323 lines
9.7 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 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.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../theme/tokens.dart';
|
|
|
|
/// Menu-value scheme: '' = all projects, `p:<slug>` = shared project,
|
|
/// `s:<slug>` = 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<String>(
|
|
onOpened: ws.refresh,
|
|
onSelected: (v) => _onSelected(context, v),
|
|
itemBuilder: (context) => _items(context, ws, l, theme),
|
|
child: _pill(context, ws, l, theme, label),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
List<PopupMenuEntry<String>> _items(
|
|
BuildContext context,
|
|
Workspace ws,
|
|
AppLocalizations l,
|
|
ThemeData theme,
|
|
) {
|
|
final items = <PopupMenuEntry<String>>[
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
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),
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
|
|
Future<void> _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);
|
|
}
|