feat(studio): clearer store + channel switcher + approvals & welcome polish
Some checks failed
Security / Security check (push) Failing after 2s

- store: repair the filter dialog crash (a Spacer lived directly in
  AlertDialog.actions, which is an OverflowBar, not a Flex — it threw
  and rendered a broken dialog). Buttons now sit in a Row.
- store: promote the module-store manager from a bare icon to a
  labelled 'Add store' button, and fully localize the dialog (DE/EN).
- store: add a curated 'Suggested stores' shelf with one-click
  add/remove (first entry: Recl∆Im). Each suggestion is probed for
  reachability and shows 'not available yet' until its index is
  published, instead of failing only on click. Fail-open on network
  errors so a transient hiccup never hides a real store.
- sidebar: the channel pill is now a one-click channel switcher
  (menu with per-channel running state + active check; switching
  writes ~/.chain/current-channel, restarts the daemon, and Studio
  repoints to the new channel).
- approvals: lead the card with the human prompt ('what am I
  releasing?') and demote the flow/step id to a metadata line; clear
  fallback when the step left the prompt empty.
- welcome: tidy the docs grid into equal-height paired rows with a
  full-width trailing card for the odd one out.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-18 21:29:39 +02:00
parent 62ed3951e5
commit 5220f19ff8
10 changed files with 865 additions and 148 deletions

View file

@ -5,6 +5,7 @@
import 'dart:async';
import 'package:chain_client_sdk/chain_client_sdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -931,47 +932,200 @@ class _SidebarState extends State<_Sidebar>
}
}
/// Active-channel indicator. Click to open Settings (where the
/// operator can switch). Color-coded so production deployments
/// look visibly different from local-dev the kind of thing
/// you want to see before clicking "Clear log".
class _ChannelPill extends StatelessWidget {
final String channel;
const _ChannelPill({required this.channel});
Color _accent(ColorScheme cs) {
switch (channel) {
case 'production':
// "Live & healthy", not an error red here read as a fault.
return ChainColors.success;
case 'beta':
return ChainColors.warning;
case 'dev':
return cs.primary;
case 'local':
default:
return cs.onSurfaceVariant;
}
/// Accent colour for a channel name. Color-coded so production
/// deployments look visibly different from local-dev the kind of
/// thing you want to see before clicking "Clear log". Shared by the
/// pill, the collapsed chip, and the switch menu so all three stay
/// in lockstep.
Color _channelAccent(String channel, ColorScheme cs) {
switch (channel) {
case 'production':
// "Live & healthy", not an error red here read as a fault.
return ChainColors.success;
case 'beta':
return ChainColors.warning;
case 'dev':
return cs.primary;
case 'local':
default:
return cs.onSurfaceVariant;
}
}
IconData get _icon {
switch (channel) {
case 'production':
return Icons.shield_outlined;
case 'beta':
return Icons.science_outlined;
case 'dev':
return Icons.bolt_outlined;
case 'local':
default:
return Icons.home_work_outlined;
}
IconData _channelIcon(String channel) {
switch (channel) {
case 'production':
return Icons.shield_outlined;
case 'beta':
return Icons.science_outlined;
case 'dev':
return Icons.bolt_outlined;
case 'local':
default:
return Icons.home_work_outlined;
}
}
/// Open the channel-switch menu anchored at [anchor]'s widget, then
/// act on the selection. Switching runs `chain channel switch`
/// (writes `~/.chain/current-channel` AND restarts that channel's
/// daemon), then repoints Studio's wire at the new channel so the
/// connection follows the switch immediately the 5s health poll
/// then refreshes the active-channel label. Shared by the expanded
/// pill and the collapsed chip.
///
/// Falls back to the full Settings dialog when the hub is down (the
/// snapshot RPC fails) so the operator can still pick + start a
/// channel from there.
Future<void> _showChannelSwitchMenu(BuildContext anchor, String current) async {
// Capture everything that reads from the context up front, before
// any await these stay valid for the rest of the flow and keep
// the analyzer happy about context-across-async-gaps.
final l = AppLocalizations.of(anchor)!;
final messenger = ScaffoldMessenger.of(anchor);
final box = anchor.findRenderObject() as RenderBox?;
final overlay = Overlay.of(anchor).context.findRenderObject() as RenderBox?;
if (box == null || overlay == null) return;
ChannelStatusSnapshot snap;
try {
snap = await HubService.instance.channelStatus();
} catch (_) {
if (anchor.mounted) ChainSettingsDialog.show(anchor);
return;
}
if (!anchor.mounted) return;
// Anchor the menu to the pill's top edge, opening downward.
final origin = box.localToGlobal(Offset.zero, ancestor: overlay);
final position = RelativeRect.fromLTRB(
origin.dx,
origin.dy + box.size.height,
overlay.size.width - origin.dx - box.size.width,
0,
);
const settingsValue = '__settings__';
final selected = await showMenu<String>(
context: anchor,
position: position,
items: [
for (final ch in snap.channels)
PopupMenuItem<String>(
value: ch.name,
child: _ChannelMenuItem(channel: ch, active: ch.name == snap.active),
),
const PopupMenuDivider(),
PopupMenuItem<String>(
value: settingsValue,
child: Row(
children: [
Icon(
Icons.settings_outlined,
size: 16,
color: Theme.of(anchor).colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Text(l.searchSettingsLabel),
],
),
),
],
);
if (selected == null || !anchor.mounted) return;
if (selected == settingsValue) {
ChainSettingsDialog.show(anchor);
return;
}
if (selected == snap.active) return;
final r = await SystemActions.chainChannelSwitch(selected);
if (!anchor.mounted) return;
if (!r.ok) {
showFaiProcessError(
anchor,
'channel.switch',
r.stdout,
r.stderr,
title: l.channelSwitchFailed(''),
);
return;
}
// Repoint Studio at the freshly-restarted channel daemon. persist:
// false keeps launch-time auto-discovery following ~/.chain, so the
// CLI pointer remains the single source of truth.
final target = snap.channels.firstWhere(
(c) => c.name == selected,
orElse: () =>
ChannelInfo(name: selected, port: 50051, running: true, endpoint: ''),
);
try {
await HubService.instance.reconnect(
HubEndpoint(host: '127.0.0.1', port: target.port),
persist: false,
);
} catch (_) {/* health poll will retry */}
if (anchor.mounted) {
messenger.showSnackBar(
SnackBar(content: Text(l.channelSwitchOk(selected))),
);
}
}
/// One row in the channel-switch menu: accent icon, name, a
/// running/stopped dot, and a check on the active channel.
class _ChannelMenuItem extends StatelessWidget {
final ChannelInfo channel;
final bool active;
const _ChannelMenuItem({required this.channel, required this.active});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _accent(theme.colorScheme);
final accent = _channelAccent(channel.name, theme.colorScheme);
return Row(
children: [
Icon(_channelIcon(channel.name), size: 15, color: accent),
const SizedBox(width: ChainSpace.sm),
Text(
channel.name,
style: ChainTheme.mono(
size: 12,
weight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(width: ChainSpace.sm),
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: channel.running
? ChainColors.success
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
),
const Spacer(),
if (active)
Icon(Icons.check, size: 16, color: theme.colorScheme.primary),
],
);
}
}
/// Active-channel indicator + switcher. Click to open the channel
/// menu and switch directly (writes `~/.chain/current-channel` and
/// restarts the daemon). Color-coded so production stays
/// unmistakeable.
class _ChannelPill extends StatelessWidget {
final String channel;
const _ChannelPill({required this.channel});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _channelAccent(channel, theme.colorScheme);
final l = AppLocalizations.of(context)!;
return Tooltip(
message: l.sidebarChannelTooltip,
@ -980,7 +1134,7 @@ class _ChannelPill extends StatelessWidget {
borderRadius: BorderRadius.circular(ChainRadius.sm),
child: InkWell(
borderRadius: BorderRadius.circular(ChainRadius.sm),
onTap: () => ChainSettingsDialog.show(context),
onTap: () => _showChannelSwitchMenu(context, channel),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: ChainSpace.sm,
@ -993,7 +1147,7 @@ class _ChannelPill extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_icon, size: 11, color: accent),
Icon(_channelIcon(channel), size: 11, color: accent),
const SizedBox(width: 4),
Text(
channel,
@ -1003,6 +1157,8 @@ class _ChannelPill extends StatelessWidget {
color: accent,
),
),
const SizedBox(width: 2),
Icon(Icons.arrow_drop_down, size: 12, color: accent),
],
),
),
@ -1054,21 +1210,6 @@ class _CollapsedChannelChip extends StatelessWidget {
final String channel;
const _CollapsedChannelChip({required this.channel});
Color _accent(ColorScheme cs) {
switch (channel) {
case 'production':
// "Live & healthy", not an error red here read as a fault.
return ChainColors.success;
case 'beta':
return ChainColors.warning;
case 'dev':
return cs.primary;
case 'local':
default:
return cs.onSurfaceVariant;
}
}
String get _letter {
if (channel.isEmpty) return '·';
return channel.substring(0, 1).toUpperCase();
@ -1077,18 +1218,29 @@ class _CollapsedChannelChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _accent(theme.colorScheme);
return Container(
width: 18,
height: 18,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: accent.withValues(alpha: 0.55)),
),
alignment: Alignment.center,
child: Text(
_letter,
style: ChainTheme.mono(size: 10, weight: FontWeight.w700, color: accent),
final accent = _channelAccent(channel, theme.colorScheme);
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showChannelSwitchMenu(context, channel),
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: accent.withValues(alpha: 0.55)),
),
alignment: Alignment.center,
child: Text(
_letter,
style: ChainTheme.mono(
size: 10,
weight: FontWeight.w700,
color: accent,
),
),
),
),
);
}