From 5220f19ff8a42e68a4bb6e4538f95f7ae0736d80 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 18 Jun 2026 21:29:39 +0200 Subject: [PATCH] feat(studio): clearer store + channel switcher + approvals & welcome polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/l10n/app_de.arb | 22 ++- lib/l10n/app_en.arb | 38 +++- lib/l10n/app_localizations.dart | 112 ++++++++++- lib/l10n/app_localizations_de.dart | 68 ++++++- lib/l10n/app_localizations_en.dart | 67 ++++++- lib/main.dart | 278 +++++++++++++++++++++------ lib/pages/approvals.dart | 46 ++++- lib/pages/store.dart | 84 ++++---- lib/pages/welcome.dart | 41 ++-- lib/widgets/chain_stores_dialog.dart | 257 +++++++++++++++++++++++-- 10 files changed, 865 insertions(+), 148 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e0e3ef2..43dad01 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -81,7 +81,7 @@ "connectionStartHub": "Hub starten", "connectionTapToStart": "antippen zum Starten", "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", "buttonSave": "Speichern", "buttonClose": "Schließen", @@ -319,6 +319,22 @@ } }, "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", "storeTodayDeck": "Von der Ch∆In-Redaktion", "storeTodayDismissTooltip": "Für diese Sitzung ausblenden", @@ -998,7 +1014,9 @@ "approvalsPillApproved": "freigegeben", "approvalsPillRejected": "abgelehnt", "approvalsPillExpired": "abgelaufen", - "approvalsPayloadPreview": "PAYLOAD-VORSCHAU", + "approvalsPayloadPreview": "DIESE DATEN WERDEN FREIGEGEBEN", + "approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich", + "approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}", "approvalsApproveButton": "Freigeben", "approvalsRejectButton": "Ablehnen", "approvalsRejectDialogTitle": "Freigabe ablehnen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 90fbe3f..ad016b5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -84,7 +84,7 @@ "connectionStartHub": "Start hub", "connectionTapToStart": "tap to start", "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", "buttonSave": "Save", "buttonClose": "Close", @@ -322,6 +322,32 @@ } }, "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", "storeTodayDeck": "From the Ch∆In editor", "storeTodayDismissTooltip": "Hide for this session", @@ -1001,7 +1027,15 @@ "approvalsPillApproved": "approved", "approvalsPillRejected": "rejected", "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", "approvalsRejectButton": "Reject", "approvalsRejectDialogTitle": "Reject approval", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2913867..524f5ff 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -545,7 +545,7 @@ abstract class AppLocalizations { /// No description provided for @sidebarChannelTooltip. /// /// 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; /// No description provided for @buttonCancel. @@ -1436,6 +1436,102 @@ abstract class AppLocalizations { /// **'Reload'** 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. /// /// In en, this message translates to: @@ -2969,9 +3065,21 @@ abstract class AppLocalizations { /// No description provided for @approvalsPayloadPreview. /// /// In en, this message translates to: - /// **'PAYLOAD PREVIEW'** + /// **'DATA TO BE RELEASED'** 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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 9b0a336..ca28652 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -261,7 +261,7 @@ class AppLocalizationsDe extends AppLocalizations { @override 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 String get buttonCancel => 'Abbrechen'; @@ -771,6 +771,61 @@ class AppLocalizationsDe extends AppLocalizations { @override 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 String get storeTodayBadge => 'HEUTE'; @@ -1712,7 +1767,16 @@ class AppLocalizationsDe extends AppLocalizations { String get approvalsPillExpired => 'abgelaufen'; @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 String get approvalsApproveButton => 'Freigeben'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c6dc667..6b961b5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -260,7 +260,7 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 String get buttonCancel => 'Cancel'; @@ -789,6 +789,61 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 String get storeTodayBadge => 'TODAY'; @@ -1722,7 +1777,15 @@ class AppLocalizationsEn extends AppLocalizations { String get approvalsPillExpired => 'expired'; @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 String get approvalsApproveButton => 'Approve'; diff --git a/lib/main.dart b/lib/main.dart index 350b9f4..208a3c4 100644 --- a/lib/main.dart +++ b/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 _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( + context: anchor, + position: position, + items: [ + for (final ch in snap.channels) + PopupMenuItem( + value: ch.name, + child: _ChannelMenuItem(channel: ch, active: ch.name == snap.active), + ), + const PopupMenuDivider(), + PopupMenuItem( + 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, + ), + ), + ), ), ); } diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index e099c58..2a091a3 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -527,6 +527,9 @@ class _ApprovalCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, 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( children: [ Checkbox( @@ -540,15 +543,7 @@ class _ApprovalCard extends StatelessWidget { tone: ChainPillTone.warning, icon: Icons.pending_outlined, ), - const SizedBox(width: ChainSpace.sm), - Expanded( - child: Text( - '${approval.flowName} › ${approval.stepId}', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), + const Spacer(), if (approval.expiresAt != null) ChainPill( label: _expiresInLabel(context, approval.expiresAt!), @@ -558,7 +553,38 @@ class _ApprovalCard extends StatelessWidget { ], ), 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) ...[ const SizedBox(height: ChainSpace.md), Padding( diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 0aea6eb..06e30ac 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -234,10 +234,15 @@ class _StorePageState extends State { onPressed: _openFilterDialog, ), ), - IconButton( - icon: const Icon(Icons.storefront_outlined, size: 18), - tooltip: 'Module stores', - onPressed: () => ChainStoresDialog.show(context), + Padding( + padding: const EdgeInsets.only(right: ChainSpace.sm), + child: OutlinedButton.icon( + icon: const Icon(Icons.add_business_outlined, size: 16), + label: Text(l.storesManagerButton), + onPressed: () => ChainStoresDialog.show(context), + style: + OutlinedButton.styleFrom(visualDensity: VisualDensity.compact), + ), ), IconButton( icon: const Icon(Icons.refresh, size: 18), @@ -1139,38 +1144,49 @@ 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: [ - TextButton( - onPressed: () { - setState(() { - _status = ''; - _source = ''; - _installedOnly = false; - // Reset puts `installableOnly` back to its default - // (on) — the operator hits Reset to escape a - // custom-filter state and expects the default view - // back, not an everything-including-planned view. - _installableOnly = true; - }); - }, - child: Text(l.storeFilterReset), - ), - const Spacer(), - TextButton( - onPressed: () => Navigator.pop(context, null), - child: Text(l.buttonCancel), - ), - FilledButton( - onPressed: () => Navigator.pop( - context, - _FilterOutcome( - status: _status, - source: _source, - installedOnly: _installedOnly, - installableOnly: _installableOnly, + Row( + children: [ + TextButton( + onPressed: () { + setState(() { + _status = ''; + _source = ''; + _installedOnly = false; + // Reset puts `installableOnly` back to its + // default (on) — the operator hits Reset to + // escape a custom-filter state and expects the + // default view back, not an everything- + // including-planned view. + _installableOnly = true; + }); + }, + child: Text(l.storeFilterReset), ), - ), - child: Text(l.storeFilterApply), + const Spacer(), + TextButton( + onPressed: () => Navigator.pop(context, null), + child: Text(l.buttonCancel), + ), + const SizedBox(width: ChainSpace.sm), + FilledButton( + onPressed: () => Navigator.pop( + context, + _FilterOutcome( + status: _status, + source: _source, + installedOnly: _installedOnly, + installableOnly: _installableOnly, + ), + ), + child: Text(l.storeFilterApply), + ), + ], ), ], ); diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 14dd4f2..6afa872 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -1003,18 +1003,37 @@ class _DocsRow extends StatelessWidget { ], ); } - final cardWidth = (constraints.maxWidth - ChainSpace.md) / 2; - return Wrap( - spacing: ChainSpace.md, - runSpacing: ChainSpace.md, - children: [ - for (final d in _kDocs) - SizedBox( - width: cardWidth, - child: _DocCard(entry: d), + // Pair the cards into equal-height rows: IntrinsicHeight + // + stretch makes the two cards in a row match height + // instead of the Wrap's ragged tops (their blurbs differ + // 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 = []; + 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: [ + Expanded(child: _DocCard(entry: left)), + const SizedBox(width: ChainSpace.md), + Expanded(child: _DocCard(entry: right)), + ], + ), ), - ], - ); + ); + } + } + return Column(children: rows); }, ), ], diff --git a/lib/widgets/chain_stores_dialog.dart b/lib/widgets/chain_stores_dialog.dart index d36ee6c..01e60b7 100644 --- a/lib/widgets/chain_stores_dialog.dart +++ b/lib/widgets/chain_stores_dialog.dart @@ -3,11 +3,15 @@ // 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. +import 'dart:async'; +import 'dart:io'; + import 'package:chain_client_sdk/chain_client_sdk.dart'; import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; import '../data/hub.dart'; +import '../l10n/app_localizations.dart'; import '../theme/tokens.dart'; class ChainStoresDialog extends StatefulWidget { @@ -28,10 +32,46 @@ class _ChainStoresDialogState extends State { final _url = TextEditingController(); 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 _reachable = {}; + @override void initState() { super.initState(); _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 _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(); + 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() => @@ -44,23 +84,29 @@ class _ChainStoresDialogState extends State { super.dispose(); } - Future _add() async { - final name = _name.text.trim(); - final url = _url.text.trim(); + Future _add() => + _addStore(_name.text.trim(), _url.text.trim(), fromFields: true); + + /// Shared add path — used by both the manual name/URL form and the + /// one-click suggested-store buttons. + Future _addStore(String name, String url, + {bool fromFields = false}) async { if (name.isEmpty || url.isEmpty) return; setState(() => _busy = true); try { final r = await HubService.instance.addStore(name: name, url: url); if (!mounted) return; - _name.clear(); - _url.clear(); + if (fromFields) { + _name.clear(); + _url.clear(); + } ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(r.message))); _reload(); } catch (e) { if (mounted) { showFaiErrorSnack(context, 'store.add', e, - title: 'Could not add store'); + title: AppLocalizations.of(context)!.storesManagerAddFailed); } } finally { if (mounted) setState(() => _busy = false); @@ -77,7 +123,7 @@ class _ChainStoresDialogState extends State { } catch (e) { if (mounted) { showFaiErrorSnack(context, 'store.remove', e, - title: 'Could not remove store'); + title: AppLocalizations.of(context)!.storesManagerRemoveFailed); } } } @@ -85,8 +131,9 @@ class _ChainStoresDialogState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; return AlertDialog( - title: const Text('Module stores'), + title: Text(l.storesManagerTitle), content: SizedBox( width: 540, child: Column( @@ -94,8 +141,7 @@ class _ChainStoresDialogState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - 'The hub merges modules from every store below into one index. ' - 'Add a store index URL to make its modules installable here.', + l.storesManagerIntro, style: theme.textTheme.bodySmall ?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), @@ -126,14 +172,14 @@ class _ChainStoresDialogState extends State { title: Text(s.name), subtitle: Text( s.url.isEmpty - ? '${s.moduleCount} modules · bundled' - : '${s.url}\n${s.moduleCount} modules', + ? l.storesManagerBundled(s.moduleCount) + : '${s.url}\n${l.storesManagerModuleCount(s.moduleCount)}', ), isThreeLine: s.url.isNotEmpty, trailing: s.removable ? IconButton( icon: const Icon(Icons.delete_outline), - tooltip: 'Remove', + tooltip: l.buttonRemove, onPressed: () => _remove(s.name), ) : const Icon(Icons.lock_outline, size: 16), @@ -144,7 +190,37 @@ class _ChainStoresDialogState extends State { ), ), 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>( + future: _future, + builder: (c, snap) { + final stores = snap.data ?? const []; + final configuredNameByUrl = { + 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), Row( children: [ @@ -152,8 +228,8 @@ class _ChainStoresDialogState extends State { flex: 2, child: TextField( controller: _name, - decoration: const InputDecoration( - labelText: 'Name', + decoration: InputDecoration( + labelText: l.storesManagerNameLabel, isDense: true, ), ), @@ -163,8 +239,8 @@ class _ChainStoresDialogState extends State { flex: 4, child: TextField( controller: _url, - decoration: const InputDecoration( - labelText: 'Index URL (https:// or file://)', + decoration: InputDecoration( + labelText: l.storesManagerUrlLabel, isDense: true, ), onSubmitted: (_) => _add(), @@ -178,7 +254,7 @@ class _ChainStoresDialogState extends State { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), + child: Text(l.buttonClose), ), FilledButton( onPressed: _busy ? null : _add, @@ -188,9 +264,150 @@ class _ChainStoresDialogState extends State { height: 16, 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 Recl∆Im'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 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, + ), + ), + ], + ), + ); + } +}