feat(studio): clearer store + channel switcher + approvals & welcome polish
Some checks failed
Security / Security check (push) Failing after 2s
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:
parent
62ed3951e5
commit
5220f19ff8
10 changed files with 865 additions and 148 deletions
278
lib/main.dart
278
lib/main.dart
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue