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

@ -81,7 +81,7 @@
"connectionStartHub": "Hub starten", "connectionStartHub": "Hub starten",
"connectionTapToStart": "antippen zum Starten", "connectionTapToStart": "antippen zum Starten",
"sidebarSettingsTooltip": "Einstellungen (⌘;)", "sidebarSettingsTooltip": "Einstellungen (⌘;)",
"sidebarChannelTooltip": "Aktiver Kanal — klicken um in Einstellungen (⌘;) zu wechseln.\nproduction = stabil · beta = Vorabversion · dev = laufend · local = Arbeitsumgebung", "sidebarChannelTooltip": "Aktiver Kanal — klicken zum Wechseln.\nproduction = stabil · beta = Vorabversion · dev = laufend · local = Arbeitsumgebung",
"buttonCancel": "Abbrechen", "buttonCancel": "Abbrechen",
"buttonSave": "Speichern", "buttonSave": "Speichern",
"buttonClose": "Schließen", "buttonClose": "Schließen",
@ -319,6 +319,22 @@
} }
}, },
"storeReloadTooltip": "Aktualisieren", "storeReloadTooltip": "Aktualisieren",
"storesManagerButton": "Store hinzufügen",
"storesManagerTitle": "Module-Stores",
"storesManagerIntro": "Der Hub führt die Module aller Stores unten zu einem Index zusammen. Füge die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.",
"storesManagerAddSection": "Store hinzufügen",
"storesManagerNameLabel": "Name",
"storesManagerUrlLabel": "Index-URL (https:// oder file://)",
"storesManagerAddAction": "Hinzufügen",
"storesManagerBundled": "{count} Module · gebündelt",
"storesManagerModuleCount": "{count} Module",
"storesManagerAddFailed": "Store konnte nicht hinzugefügt werden",
"storesManagerRemoveFailed": "Store konnte nicht entfernt werden",
"storesManagerSuggested": "Vorgeschlagene Stores",
"storesManagerAddShort": "Hinzufügen",
"storesManagerNotAvailable": "Noch nicht verfügbar",
"storesManagerNotAvailableHint": "Der Anbieter hat diesen Store-Index noch nicht veröffentlicht. Sobald er das tut, ist er installierbar.",
"storesSuggestedReclaimDesc": "Recl∆Im — Module für Rechts- und Pflichten-Reform-Mapping",
"storeTodayBadge": "HEUTE", "storeTodayBadge": "HEUTE",
"storeTodayDeck": "Von der Ch∆In-Redaktion", "storeTodayDeck": "Von der Ch∆In-Redaktion",
"storeTodayDismissTooltip": "Für diese Sitzung ausblenden", "storeTodayDismissTooltip": "Für diese Sitzung ausblenden",
@ -998,7 +1014,9 @@
"approvalsPillApproved": "freigegeben", "approvalsPillApproved": "freigegeben",
"approvalsPillRejected": "abgelehnt", "approvalsPillRejected": "abgelehnt",
"approvalsPillExpired": "abgelaufen", "approvalsPillExpired": "abgelaufen",
"approvalsPayloadPreview": "PAYLOAD-VORSCHAU", "approvalsPayloadPreview": "DIESE DATEN WERDEN FREIGEGEBEN",
"approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich",
"approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}",
"approvalsApproveButton": "Freigeben", "approvalsApproveButton": "Freigeben",
"approvalsRejectButton": "Ablehnen", "approvalsRejectButton": "Ablehnen",
"approvalsRejectDialogTitle": "Freigabe ablehnen", "approvalsRejectDialogTitle": "Freigabe ablehnen",

View file

@ -84,7 +84,7 @@
"connectionStartHub": "Start hub", "connectionStartHub": "Start hub",
"connectionTapToStart": "tap to start", "connectionTapToStart": "tap to start",
"sidebarSettingsTooltip": "Settings (⌘;)", "sidebarSettingsTooltip": "Settings (⌘;)",
"sidebarChannelTooltip": "Active channel — click to switch in Settings (⌘;).\nproduction = stable · beta = pre-release · dev = rolling · local = workspace", "sidebarChannelTooltip": "Active channel — click to switch.\nproduction = stable · beta = pre-release · dev = rolling · local = workspace",
"buttonCancel": "Cancel", "buttonCancel": "Cancel",
"buttonSave": "Save", "buttonSave": "Save",
"buttonClose": "Close", "buttonClose": "Close",
@ -322,6 +322,32 @@
} }
}, },
"storeReloadTooltip": "Reload", "storeReloadTooltip": "Reload",
"storesManagerButton": "Add store",
"storesManagerTitle": "Module stores",
"storesManagerIntro": "The hub merges the modules from every store below into one index. Add a store's index URL to make its modules installable here.",
"storesManagerAddSection": "Add a store",
"storesManagerNameLabel": "Name",
"storesManagerUrlLabel": "Index URL (https:// or file://)",
"storesManagerAddAction": "Add store",
"storesManagerBundled": "{count} modules · bundled",
"@storesManagerBundled": {
"placeholders": {
"count": { "type": "int" }
}
},
"storesManagerModuleCount": "{count} modules",
"@storesManagerModuleCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"storesManagerAddFailed": "Could not add store",
"storesManagerRemoveFailed": "Could not remove store",
"storesManagerSuggested": "Suggested stores",
"storesManagerAddShort": "Add",
"storesManagerNotAvailable": "Not available yet",
"storesManagerNotAvailableHint": "The provider hasn't published this store index yet. It will become installable once they do.",
"storesSuggestedReclaimDesc": "Recl∆Im — modules for law & duty reform-mapping",
"storeTodayBadge": "TODAY", "storeTodayBadge": "TODAY",
"storeTodayDeck": "From the Ch∆In editor", "storeTodayDeck": "From the Ch∆In editor",
"storeTodayDismissTooltip": "Hide for this session", "storeTodayDismissTooltip": "Hide for this session",
@ -1001,7 +1027,15 @@
"approvalsPillApproved": "approved", "approvalsPillApproved": "approved",
"approvalsPillRejected": "rejected", "approvalsPillRejected": "rejected",
"approvalsPillExpired": "expired", "approvalsPillExpired": "expired",
"approvalsPayloadPreview": "PAYLOAD PREVIEW", "approvalsPayloadPreview": "DATA TO BE RELEASED",
"approvalsRequestFallback": "Approval required for this step",
"approvalsFlowStepMeta": "Flow: {flow} · Step: {step}",
"@approvalsFlowStepMeta": {
"placeholders": {
"flow": { "type": "String" },
"step": { "type": "String" }
}
},
"approvalsApproveButton": "Approve", "approvalsApproveButton": "Approve",
"approvalsRejectButton": "Reject", "approvalsRejectButton": "Reject",
"approvalsRejectDialogTitle": "Reject approval", "approvalsRejectDialogTitle": "Reject approval",

View file

@ -545,7 +545,7 @@ abstract class AppLocalizations {
/// No description provided for @sidebarChannelTooltip. /// No description provided for @sidebarChannelTooltip.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Active channel — click to switch in Settings (⌘;).\nproduction = stable · beta = pre-release · dev = rolling · local = workspace'** /// **'Active channel — click to switch.\nproduction = stable · beta = pre-release · dev = rolling · local = workspace'**
String get sidebarChannelTooltip; String get sidebarChannelTooltip;
/// No description provided for @buttonCancel. /// No description provided for @buttonCancel.
@ -1436,6 +1436,102 @@ abstract class AppLocalizations {
/// **'Reload'** /// **'Reload'**
String get storeReloadTooltip; String get storeReloadTooltip;
/// No description provided for @storesManagerButton.
///
/// In en, this message translates to:
/// **'Add store'**
String get storesManagerButton;
/// No description provided for @storesManagerTitle.
///
/// In en, this message translates to:
/// **'Module stores'**
String get storesManagerTitle;
/// No description provided for @storesManagerIntro.
///
/// In en, this message translates to:
/// **'The hub merges the modules from every store below into one index. Add a store\'s index URL to make its modules installable here.'**
String get storesManagerIntro;
/// No description provided for @storesManagerAddSection.
///
/// In en, this message translates to:
/// **'Add a store'**
String get storesManagerAddSection;
/// No description provided for @storesManagerNameLabel.
///
/// In en, this message translates to:
/// **'Name'**
String get storesManagerNameLabel;
/// No description provided for @storesManagerUrlLabel.
///
/// In en, this message translates to:
/// **'Index URL (https:// or file://)'**
String get storesManagerUrlLabel;
/// No description provided for @storesManagerAddAction.
///
/// In en, this message translates to:
/// **'Add store'**
String get storesManagerAddAction;
/// No description provided for @storesManagerBundled.
///
/// In en, this message translates to:
/// **'{count} modules · bundled'**
String storesManagerBundled(int count);
/// No description provided for @storesManagerModuleCount.
///
/// In en, this message translates to:
/// **'{count} modules'**
String storesManagerModuleCount(int count);
/// No description provided for @storesManagerAddFailed.
///
/// In en, this message translates to:
/// **'Could not add store'**
String get storesManagerAddFailed;
/// No description provided for @storesManagerRemoveFailed.
///
/// In en, this message translates to:
/// **'Could not remove store'**
String get storesManagerRemoveFailed;
/// No description provided for @storesManagerSuggested.
///
/// In en, this message translates to:
/// **'Suggested stores'**
String get storesManagerSuggested;
/// No description provided for @storesManagerAddShort.
///
/// In en, this message translates to:
/// **'Add'**
String get storesManagerAddShort;
/// No description provided for @storesManagerNotAvailable.
///
/// In en, this message translates to:
/// **'Not available yet'**
String get storesManagerNotAvailable;
/// No description provided for @storesManagerNotAvailableHint.
///
/// In en, this message translates to:
/// **'The provider hasn\'t published this store index yet. It will become installable once they do.'**
String get storesManagerNotAvailableHint;
/// No description provided for @storesSuggestedReclaimDesc.
///
/// In en, this message translates to:
/// **'Recl∆Im — modules for law & duty reform-mapping'**
String get storesSuggestedReclaimDesc;
/// No description provided for @storeTodayBadge. /// No description provided for @storeTodayBadge.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -2969,9 +3065,21 @@ abstract class AppLocalizations {
/// No description provided for @approvalsPayloadPreview. /// No description provided for @approvalsPayloadPreview.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'PAYLOAD PREVIEW'** /// **'DATA TO BE RELEASED'**
String get approvalsPayloadPreview; String get approvalsPayloadPreview;
/// No description provided for @approvalsRequestFallback.
///
/// In en, this message translates to:
/// **'Approval required for this step'**
String get approvalsRequestFallback;
/// No description provided for @approvalsFlowStepMeta.
///
/// In en, this message translates to:
/// **'Flow: {flow} · Step: {step}'**
String approvalsFlowStepMeta(String flow, String step);
/// No description provided for @approvalsApproveButton. /// No description provided for @approvalsApproveButton.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -261,7 +261,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get sidebarChannelTooltip => String get sidebarChannelTooltip =>
'Aktiver Kanal — klicken um in Einstellungen (⌘;) zu wechseln.\nproduction = stabil · beta = Vorabversion · dev = laufend · local = Arbeitsumgebung'; 'Aktiver Kanal — klicken zum Wechseln.\nproduction = stabil · beta = Vorabversion · dev = laufend · local = Arbeitsumgebung';
@override @override
String get buttonCancel => 'Abbrechen'; String get buttonCancel => 'Abbrechen';
@ -771,6 +771,61 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get storeReloadTooltip => 'Aktualisieren'; String get storeReloadTooltip => 'Aktualisieren';
@override
String get storesManagerButton => 'Store hinzufügen';
@override
String get storesManagerTitle => 'Module-Stores';
@override
String get storesManagerIntro =>
'Der Hub führt die Module aller Stores unten zu einem Index zusammen. Füge die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.';
@override
String get storesManagerAddSection => 'Store hinzufügen';
@override
String get storesManagerNameLabel => 'Name';
@override
String get storesManagerUrlLabel => 'Index-URL (https:// oder file://)';
@override
String get storesManagerAddAction => 'Hinzufügen';
@override
String storesManagerBundled(int count) {
return '$count Module · gebündelt';
}
@override
String storesManagerModuleCount(int count) {
return '$count Module';
}
@override
String get storesManagerAddFailed => 'Store konnte nicht hinzugefügt werden';
@override
String get storesManagerRemoveFailed => 'Store konnte nicht entfernt werden';
@override
String get storesManagerSuggested => 'Vorgeschlagene Stores';
@override
String get storesManagerAddShort => 'Hinzufügen';
@override
String get storesManagerNotAvailable => 'Noch nicht verfügbar';
@override
String get storesManagerNotAvailableHint =>
'Der Anbieter hat diesen Store-Index noch nicht veröffentlicht. Sobald er das tut, ist er installierbar.';
@override
String get storesSuggestedReclaimDesc =>
'Recl∆Im — Module für Rechts- und Pflichten-Reform-Mapping';
@override @override
String get storeTodayBadge => 'HEUTE'; String get storeTodayBadge => 'HEUTE';
@ -1712,7 +1767,16 @@ class AppLocalizationsDe extends AppLocalizations {
String get approvalsPillExpired => 'abgelaufen'; String get approvalsPillExpired => 'abgelaufen';
@override @override
String get approvalsPayloadPreview => 'PAYLOAD-VORSCHAU'; String get approvalsPayloadPreview => 'DIESE DATEN WERDEN FREIGEGEBEN';
@override
String get approvalsRequestFallback =>
'Freigabe für diesen Schritt erforderlich';
@override
String approvalsFlowStepMeta(String flow, String step) {
return 'Flow: $flow · Schritt: $step';
}
@override @override
String get approvalsApproveButton => 'Freigeben'; String get approvalsApproveButton => 'Freigeben';

View file

@ -260,7 +260,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get sidebarChannelTooltip => String get sidebarChannelTooltip =>
'Active channel — click to switch in Settings (⌘;).\nproduction = stable · beta = pre-release · dev = rolling · local = workspace'; 'Active channel — click to switch.\nproduction = stable · beta = pre-release · dev = rolling · local = workspace';
@override @override
String get buttonCancel => 'Cancel'; String get buttonCancel => 'Cancel';
@ -789,6 +789,61 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get storeReloadTooltip => 'Reload'; String get storeReloadTooltip => 'Reload';
@override
String get storesManagerButton => 'Add store';
@override
String get storesManagerTitle => 'Module stores';
@override
String get storesManagerIntro =>
'The hub merges the modules from every store below into one index. Add a store\'s index URL to make its modules installable here.';
@override
String get storesManagerAddSection => 'Add a store';
@override
String get storesManagerNameLabel => 'Name';
@override
String get storesManagerUrlLabel => 'Index URL (https:// or file://)';
@override
String get storesManagerAddAction => 'Add store';
@override
String storesManagerBundled(int count) {
return '$count modules · bundled';
}
@override
String storesManagerModuleCount(int count) {
return '$count modules';
}
@override
String get storesManagerAddFailed => 'Could not add store';
@override
String get storesManagerRemoveFailed => 'Could not remove store';
@override
String get storesManagerSuggested => 'Suggested stores';
@override
String get storesManagerAddShort => 'Add';
@override
String get storesManagerNotAvailable => 'Not available yet';
@override
String get storesManagerNotAvailableHint =>
'The provider hasn\'t published this store index yet. It will become installable once they do.';
@override
String get storesSuggestedReclaimDesc =>
'Recl∆Im — modules for law & duty reform-mapping';
@override @override
String get storeTodayBadge => 'TODAY'; String get storeTodayBadge => 'TODAY';
@ -1722,7 +1777,15 @@ class AppLocalizationsEn extends AppLocalizations {
String get approvalsPillExpired => 'expired'; String get approvalsPillExpired => 'expired';
@override @override
String get approvalsPayloadPreview => 'PAYLOAD PREVIEW'; String get approvalsPayloadPreview => 'DATA TO BE RELEASED';
@override
String get approvalsRequestFallback => 'Approval required for this step';
@override
String approvalsFlowStepMeta(String flow, String step) {
return 'Flow: $flow · Step: $step';
}
@override @override
String get approvalsApproveButton => 'Approve'; String get approvalsApproveButton => 'Approve';

View file

@ -5,6 +5,7 @@
import 'dart:async'; import 'dart:async';
import 'package:chain_client_sdk/chain_client_sdk.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -931,15 +932,12 @@ class _SidebarState extends State<_Sidebar>
} }
} }
/// Active-channel indicator. Click to open Settings (where the /// Accent colour for a channel name. Color-coded so production
/// operator can switch). Color-coded so production deployments /// deployments look visibly different from local-dev the kind of
/// look visibly different from local-dev the kind of thing /// thing you want to see before clicking "Clear log". Shared by the
/// you want to see before clicking "Clear log". /// pill, the collapsed chip, and the switch menu so all three stay
class _ChannelPill extends StatelessWidget { /// in lockstep.
final String channel; Color _channelAccent(String channel, ColorScheme cs) {
const _ChannelPill({required this.channel});
Color _accent(ColorScheme cs) {
switch (channel) { switch (channel) {
case 'production': case 'production':
// "Live & healthy", not an error red here read as a fault. // "Live & healthy", not an error red here read as a fault.
@ -954,7 +952,7 @@ class _ChannelPill extends StatelessWidget {
} }
} }
IconData get _icon { IconData _channelIcon(String channel) {
switch (channel) { switch (channel) {
case 'production': case 'production':
return Icons.shield_outlined; return Icons.shield_outlined;
@ -968,10 +966,166 @@ class _ChannelPill extends StatelessWidget {
} }
} }
/// 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(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)!; final l = AppLocalizations.of(context)!;
return Tooltip( return Tooltip(
message: l.sidebarChannelTooltip, message: l.sidebarChannelTooltip,
@ -980,7 +1134,7 @@ class _ChannelPill extends StatelessWidget {
borderRadius: BorderRadius.circular(ChainRadius.sm), borderRadius: BorderRadius.circular(ChainRadius.sm),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(ChainRadius.sm), borderRadius: BorderRadius.circular(ChainRadius.sm),
onTap: () => ChainSettingsDialog.show(context), onTap: () => _showChannelSwitchMenu(context, channel),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: ChainSpace.sm, horizontal: ChainSpace.sm,
@ -993,7 +1147,7 @@ class _ChannelPill extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(_icon, size: 11, color: accent), Icon(_channelIcon(channel), size: 11, color: accent),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
channel, channel,
@ -1003,6 +1157,8 @@ class _ChannelPill extends StatelessWidget {
color: accent, 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; final String channel;
const _CollapsedChannelChip({required this.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 { String get _letter {
if (channel.isEmpty) return '·'; if (channel.isEmpty) return '·';
return channel.substring(0, 1).toUpperCase(); return channel.substring(0, 1).toUpperCase();
@ -1077,8 +1218,13 @@ class _CollapsedChannelChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final accent = _accent(theme.colorScheme); final accent = _channelAccent(channel, theme.colorScheme);
return Container( return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showChannelSwitchMenu(context, channel),
child: Container(
width: 18, width: 18,
height: 18, height: 18,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -1088,7 +1234,13 @@ class _CollapsedChannelChip extends StatelessWidget {
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
_letter, _letter,
style: ChainTheme.mono(size: 10, weight: FontWeight.w700, color: accent), style: ChainTheme.mono(
size: 10,
weight: FontWeight.w700,
color: accent,
),
),
),
), ),
); );
} }

View file

@ -527,6 +527,9 @@ class _ApprovalCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Status row: selection + state + expiry. No flow id here
// the headline below leads with WHAT is being asked, not the
// technical flow name.
Row( Row(
children: [ children: [
Checkbox( Checkbox(
@ -540,15 +543,7 @@ class _ApprovalCard extends StatelessWidget {
tone: ChainPillTone.warning, tone: ChainPillTone.warning,
icon: Icons.pending_outlined, icon: Icons.pending_outlined,
), ),
const SizedBox(width: ChainSpace.sm), const Spacer(),
Expanded(
child: Text(
'${approval.flowName} ${approval.stepId}',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
if (approval.expiresAt != null) if (approval.expiresAt != null)
ChainPill( ChainPill(
label: _expiresInLabel(context, approval.expiresAt!), label: _expiresInLabel(context, approval.expiresAt!),
@ -558,7 +553,38 @@ class _ApprovalCard extends StatelessWidget {
], ],
), ),
const SizedBox(height: ChainSpace.lg), const SizedBox(height: ChainSpace.lg),
Text(approval.prompt, style: theme.textTheme.bodyLarge), // Headline = the human question the flow author wrote ("what
// am I approving"). Falls back to a clear sentence when the
// step left the prompt empty, so the card is never reduced to
// a cryptic flow id.
Text(
approval.prompt.trim().isEmpty
? l.approvalsRequestFallback
: approval.prompt,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
// Technical context, demoted to a small metadata line.
Row(
children: [
Icon(
Icons.account_tree_outlined,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
l.approvalsFlowStepMeta(approval.flowName, approval.stepId),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
if (approval.payloadPreview != null) ...[ if (approval.payloadPreview != null) ...[
const SizedBox(height: ChainSpace.md), const SizedBox(height: ChainSpace.md),
Padding( Padding(

View file

@ -234,10 +234,15 @@ class _StorePageState extends State<StorePage> {
onPressed: _openFilterDialog, onPressed: _openFilterDialog,
), ),
), ),
IconButton( Padding(
icon: const Icon(Icons.storefront_outlined, size: 18), padding: const EdgeInsets.only(right: ChainSpace.sm),
tooltip: 'Module stores', child: OutlinedButton.icon(
icon: const Icon(Icons.add_business_outlined, size: 16),
label: Text(l.storesManagerButton),
onPressed: () => ChainStoresDialog.show(context), onPressed: () => ChainStoresDialog.show(context),
style:
OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
),
), ),
IconButton( IconButton(
icon: const Icon(Icons.refresh, size: 18), icon: const Icon(Icons.refresh, size: 18),
@ -1139,17 +1144,25 @@ class _FilterDialogState extends State<_FilterDialog> {
], ],
), ),
), ),
// A single Row is the one action child: AlertDialog wraps
// `actions` in an OverflowBar (not a Flex), so a bare
// `Spacer()` here throws it needs a Flex parent. Nesting
// the buttons in a Row gives the Spacer a valid Flex
// ancestor and keeps Reset left / Cancel+Apply right.
actions: [ actions: [
Row(
children: [
TextButton( TextButton(
onPressed: () { onPressed: () {
setState(() { setState(() {
_status = ''; _status = '';
_source = ''; _source = '';
_installedOnly = false; _installedOnly = false;
// Reset puts `installableOnly` back to its default // Reset puts `installableOnly` back to its
// (on) the operator hits Reset to escape a // default (on) the operator hits Reset to
// custom-filter state and expects the default view // escape a custom-filter state and expects the
// back, not an everything-including-planned view. // default view back, not an everything-
// including-planned view.
_installableOnly = true; _installableOnly = true;
}); });
}, },
@ -1160,6 +1173,7 @@ class _FilterDialogState extends State<_FilterDialog> {
onPressed: () => Navigator.pop(context, null), onPressed: () => Navigator.pop(context, null),
child: Text(l.buttonCancel), child: Text(l.buttonCancel),
), ),
const SizedBox(width: ChainSpace.sm),
FilledButton( FilledButton(
onPressed: () => Navigator.pop( onPressed: () => Navigator.pop(
context, context,
@ -1173,6 +1187,8 @@ class _FilterDialogState extends State<_FilterDialog> {
child: Text(l.storeFilterApply), child: Text(l.storeFilterApply),
), ),
], ],
),
],
); );
} }
} }

View file

@ -1003,18 +1003,37 @@ class _DocsRow extends StatelessWidget {
], ],
); );
} }
final cardWidth = (constraints.maxWidth - ChainSpace.md) / 2; // Pair the cards into equal-height rows: IntrinsicHeight
return Wrap( // + stretch makes the two cards in a row match height
spacing: ChainSpace.md, // instead of the Wrap's ragged tops (their blurbs differ
runSpacing: ChainSpace.md, // in length, especially across locales). An odd trailing
// card spans the full width so it reads as intentional
// rather than a lonely half-box with dead space beside it.
final rows = <Widget>[];
for (var i = 0; i < _kDocs.length; i += 2) {
if (rows.isNotEmpty) {
rows.add(const SizedBox(height: ChainSpace.md));
}
final left = _kDocs[i];
final right = i + 1 < _kDocs.length ? _kDocs[i + 1] : null;
if (right == null) {
rows.add(_DocCard(entry: left));
} else {
rows.add(
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
for (final d in _kDocs) Expanded(child: _DocCard(entry: left)),
SizedBox( const SizedBox(width: ChainSpace.md),
width: cardWidth, Expanded(child: _DocCard(entry: right)),
child: _DocCard(entry: d),
),
], ],
),
),
); );
}
}
return Column(children: rows);
}, },
), ),
], ],

View file

@ -3,11 +3,15 @@
// every store's modules into one searchable index, so adding a store // every store's modules into one searchable index, so adding a store
// (e.g. a domain app's published modules) makes them appear in the Store. // (e.g. a domain app's published modules) makes them appear in the Store.
import 'dart:async';
import 'dart:io';
import 'package:chain_client_sdk/chain_client_sdk.dart'; import 'package:chain_client_sdk/chain_client_sdk.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../data/error_presentation.dart'; import '../data/error_presentation.dart';
import '../data/hub.dart'; import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
class ChainStoresDialog extends StatefulWidget { class ChainStoresDialog extends StatefulWidget {
@ -28,10 +32,46 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
final _url = TextEditingController(); final _url = TextEditingController();
bool _busy = false; bool _busy = false;
/// Reachability of each suggested store's index URL. `null` while the
/// probe is in flight, `true` once a response arrives that the hub
/// could parse (fail-open: network errors also count as `true`, so a
/// transient hiccup never hides a real store), `false` only on a
/// definitive 4xx/5xx i.e. "the provider hasn't published it yet".
final Map<String, bool?> _reachable = {};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_reload(); _reload();
for (final s in _kSuggestedStores) {
_reachable[s.url] = null;
_probe(s.url);
}
}
/// One-shot HEAD-ish probe of a suggested store's index. Fails open:
/// only a clean 4xx/5xx marks it unavailable; connection errors and
/// timeouts leave it available so the hub stays the real authority.
Future<void> _probe(String url) async {
if (!url.startsWith('http')) {
if (mounted) setState(() => _reachable[url] = true);
return;
}
bool available = true;
try {
final client = HttpClient()
..connectionTimeout = const Duration(seconds: 4);
final req = await client.getUrl(Uri.parse(url));
final resp = await req.close().timeout(const Duration(seconds: 5));
available = resp.statusCode >= 200 && resp.statusCode < 400;
await resp.drain<void>();
client.close(force: true);
} catch (_) {
// Offline / DNS / timeout fail open. The add path will surface
// any real error if the operator clicks through.
available = true;
}
if (mounted) setState(() => _reachable[url] = available);
} }
void _reload() => void _reload() =>
@ -44,23 +84,29 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
super.dispose(); super.dispose();
} }
Future<void> _add() async { Future<void> _add() =>
final name = _name.text.trim(); _addStore(_name.text.trim(), _url.text.trim(), fromFields: true);
final url = _url.text.trim();
/// Shared add path used by both the manual name/URL form and the
/// one-click suggested-store buttons.
Future<void> _addStore(String name, String url,
{bool fromFields = false}) async {
if (name.isEmpty || url.isEmpty) return; if (name.isEmpty || url.isEmpty) return;
setState(() => _busy = true); setState(() => _busy = true);
try { try {
final r = await HubService.instance.addStore(name: name, url: url); final r = await HubService.instance.addStore(name: name, url: url);
if (!mounted) return; if (!mounted) return;
if (fromFields) {
_name.clear(); _name.clear();
_url.clear(); _url.clear();
}
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(r.message))); .showSnackBar(SnackBar(content: Text(r.message)));
_reload(); _reload();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showFaiErrorSnack(context, 'store.add', e, showFaiErrorSnack(context, 'store.add', e,
title: 'Could not add store'); title: AppLocalizations.of(context)!.storesManagerAddFailed);
} }
} finally { } finally {
if (mounted) setState(() => _busy = false); if (mounted) setState(() => _busy = false);
@ -77,7 +123,7 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showFaiErrorSnack(context, 'store.remove', e, showFaiErrorSnack(context, 'store.remove', e,
title: 'Could not remove store'); title: AppLocalizations.of(context)!.storesManagerRemoveFailed);
} }
} }
} }
@ -85,8 +131,9 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog( return AlertDialog(
title: const Text('Module stores'), title: Text(l.storesManagerTitle),
content: SizedBox( content: SizedBox(
width: 540, width: 540,
child: Column( child: Column(
@ -94,8 +141,7 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
'The hub merges modules from every store below into one index. ' l.storesManagerIntro,
'Add a store index URL to make its modules installable here.',
style: theme.textTheme.bodySmall style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant), ?.copyWith(color: theme.colorScheme.onSurfaceVariant),
), ),
@ -126,14 +172,14 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
title: Text(s.name), title: Text(s.name),
subtitle: Text( subtitle: Text(
s.url.isEmpty s.url.isEmpty
? '${s.moduleCount} modules · bundled' ? l.storesManagerBundled(s.moduleCount)
: '${s.url}\n${s.moduleCount} modules', : '${s.url}\n${l.storesManagerModuleCount(s.moduleCount)}',
), ),
isThreeLine: s.url.isNotEmpty, isThreeLine: s.url.isNotEmpty,
trailing: s.removable trailing: s.removable
? IconButton( ? IconButton(
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
tooltip: 'Remove', tooltip: l.buttonRemove,
onPressed: () => _remove(s.name), onPressed: () => _remove(s.name),
) )
: const Icon(Icons.lock_outline, size: 16), : const Icon(Icons.lock_outline, size: 16),
@ -144,7 +190,37 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
), ),
), ),
const Divider(), const Divider(),
Text('Add a store', style: theme.textTheme.titleSmall), // One-click curated stores add or remove a known store
// (e.g. a domain app's published modules) without typing a
// URL. State follows the configured list above.
Text(l.storesManagerSuggested, style: theme.textTheme.titleSmall),
const SizedBox(height: ChainSpace.sm),
FutureBuilder<List<StoreSource>>(
future: _future,
builder: (c, snap) {
final stores = snap.data ?? const <StoreSource>[];
final configuredNameByUrl = <String, String>{
for (final s in stores)
if (s.url.isNotEmpty) s.url: s.name,
};
return Column(
children: [
for (final sg in _kSuggestedStores)
_SuggestedStoreRow(
store: sg,
configuredName: configuredNameByUrl[sg.url],
available: _reachable[sg.url],
busy: _busy,
onAdd: () => _addStore(sg.name, sg.url),
onRemove: (name) => _remove(name),
),
],
);
},
),
const SizedBox(height: ChainSpace.md),
const Divider(),
Text(l.storesManagerAddSection, style: theme.textTheme.titleSmall),
const SizedBox(height: ChainSpace.sm), const SizedBox(height: ChainSpace.sm),
Row( Row(
children: [ children: [
@ -152,8 +228,8 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
flex: 2, flex: 2,
child: TextField( child: TextField(
controller: _name, controller: _name,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Name', labelText: l.storesManagerNameLabel,
isDense: true, isDense: true,
), ),
), ),
@ -163,8 +239,8 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
flex: 4, flex: 4,
child: TextField( child: TextField(
controller: _url, controller: _url,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Index URL (https:// or file://)', labelText: l.storesManagerUrlLabel,
isDense: true, isDense: true,
), ),
onSubmitted: (_) => _add(), onSubmitted: (_) => _add(),
@ -178,7 +254,7 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'), child: Text(l.buttonClose),
), ),
FilledButton( FilledButton(
onPressed: _busy ? null : _add, onPressed: _busy ? null : _add,
@ -188,9 +264,150 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
height: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Text('Add store'), : Text(l.storesManagerAddAction),
), ),
], ],
); );
} }
} }
/// A curated, one-click store the operator can add or remove without
/// typing a URL. The hub validates the index on add, so the entry
/// only succeeds once its index is actually published at [url].
class _SuggestedStore {
final String name;
final String url;
final String Function(AppLocalizations) describe;
const _SuggestedStore({
required this.name,
required this.url,
required this.describe,
});
}
/// Known stores offered as quick-adds. Keep this short and curated
/// it is the in-product shelf of trusted domain stores. The URL is the
/// canonical publish location each store owner commits to (see the
/// cross-agent handoff log for ReclIm's).
final List<_SuggestedStore> _kSuggestedStores = <_SuggestedStore>[
_SuggestedStore(
name: 'Recl∆Im',
url: 'https://releases.chain.flemming.ai/reclaim/store.yaml',
describe: (l) => l.storesSuggestedReclaimDesc,
),
];
/// One row in the suggested-stores shelf: icon, name, description, and
/// a single Add/Remove button that flips based on whether the store is
/// already configured.
class _SuggestedStoreRow extends StatelessWidget {
final _SuggestedStore store;
/// The configured store's name when this suggestion is already
/// added (matched by URL), else null.
final String? configuredName;
/// Index reachability: null = still probing, true = reachable,
/// false = a definitive 4xx/5xx (not published yet).
final bool? available;
final bool busy;
final VoidCallback onAdd;
final ValueChanged<String> onRemove;
const _SuggestedStoreRow({
required this.store,
required this.configuredName,
required this.available,
required this.busy,
required this.onAdd,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final added = configuredName != null;
// Not-yet-published: a definitive bad response and not already
// configured. Show a muted "not available yet" chip instead of an
// Add button that would only fail on click.
final notPublished = !added && available == false;
final probing = !added && available == null;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(
Icons.storefront_outlined,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: ChainSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
store.name,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
Text(
store.describe(l),
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
),
const SizedBox(width: ChainSpace.sm),
if (added)
OutlinedButton.icon(
icon: const Icon(Icons.check, size: 14),
label: Text(l.buttonRemove),
onPressed: busy ? null : () => onRemove(configuredName!),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: ChainColors.success,
),
)
else if (probing)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (notPublished)
Tooltip(
message: l.storesManagerNotAvailableHint,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.schedule_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
l.storesManagerNotAvailable,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
)
else
FilledButton.tonalIcon(
icon: const Icon(Icons.add, size: 14),
label: Text(l.storesManagerAddShort),
onPressed: busy ? null : onAdd,
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
);
}
}