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
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
278
lib/main.dart
278
lib/main.dart
|
|
@ -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,47 +932,200 @@ 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});
|
switch (channel) {
|
||||||
|
case 'production':
|
||||||
Color _accent(ColorScheme cs) {
|
// "Live & healthy", not an error — red here read as a fault.
|
||||||
switch (channel) {
|
return ChainColors.success;
|
||||||
case 'production':
|
case 'beta':
|
||||||
// "Live & healthy", not an error — red here read as a fault.
|
return ChainColors.warning;
|
||||||
return ChainColors.success;
|
case 'dev':
|
||||||
case 'beta':
|
return cs.primary;
|
||||||
return ChainColors.warning;
|
case 'local':
|
||||||
case 'dev':
|
default:
|
||||||
return cs.primary;
|
return cs.onSurfaceVariant;
|
||||||
case 'local':
|
|
||||||
default:
|
|
||||||
return cs.onSurfaceVariant;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
IconData get _icon {
|
IconData _channelIcon(String channel) {
|
||||||
switch (channel) {
|
switch (channel) {
|
||||||
case 'production':
|
case 'production':
|
||||||
return Icons.shield_outlined;
|
return Icons.shield_outlined;
|
||||||
case 'beta':
|
case 'beta':
|
||||||
return Icons.science_outlined;
|
return Icons.science_outlined;
|
||||||
case 'dev':
|
case 'dev':
|
||||||
return Icons.bolt_outlined;
|
return Icons.bolt_outlined;
|
||||||
case 'local':
|
case 'local':
|
||||||
default:
|
default:
|
||||||
return Icons.home_work_outlined;
|
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
|
@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,18 +1218,29 @@ 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(
|
||||||
width: 18,
|
cursor: SystemMouseCursors.click,
|
||||||
height: 18,
|
child: GestureDetector(
|
||||||
decoration: BoxDecoration(
|
behavior: HitTestBehavior.opaque,
|
||||||
shape: BoxShape.circle,
|
onTap: () => _showChannelSwitchMenu(context, channel),
|
||||||
border: Border.all(color: accent.withValues(alpha: 0.55)),
|
child: Container(
|
||||||
),
|
width: 18,
|
||||||
alignment: Alignment.center,
|
height: 18,
|
||||||
child: Text(
|
decoration: BoxDecoration(
|
||||||
_letter,
|
shape: BoxShape.circle,
|
||||||
style: ChainTheme.mono(size: 10, weight: FontWeight.w700, color: accent),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
onPressed: () => ChainStoresDialog.show(context),
|
icon: const Icon(Icons.add_business_outlined, size: 16),
|
||||||
|
label: Text(l.storesManagerButton),
|
||||||
|
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,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: [
|
actions: [
|
||||||
TextButton(
|
Row(
|
||||||
onPressed: () {
|
children: [
|
||||||
setState(() {
|
TextButton(
|
||||||
_status = '';
|
onPressed: () {
|
||||||
_source = '';
|
setState(() {
|
||||||
_installedOnly = false;
|
_status = '';
|
||||||
// Reset puts `installableOnly` back to its default
|
_source = '';
|
||||||
// (on) — the operator hits Reset to escape a
|
_installedOnly = false;
|
||||||
// custom-filter state and expects the default view
|
// Reset puts `installableOnly` back to its
|
||||||
// back, not an everything-including-planned view.
|
// default (on) — the operator hits Reset to
|
||||||
_installableOnly = true;
|
// escape a custom-filter state and expects the
|
||||||
});
|
// default view back, not an everything-
|
||||||
},
|
// including-planned view.
|
||||||
child: Text(l.storeFilterReset),
|
_installableOnly = true;
|
||||||
),
|
});
|
||||||
const Spacer(),
|
},
|
||||||
TextButton(
|
child: Text(l.storeFilterReset),
|
||||||
onPressed: () => Navigator.pop(context, null),
|
|
||||||
child: Text(l.buttonCancel),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.pop(
|
|
||||||
context,
|
|
||||||
_FilterOutcome(
|
|
||||||
status: _status,
|
|
||||||
source: _source,
|
|
||||||
installedOnly: _installedOnly,
|
|
||||||
installableOnly: _installableOnly,
|
|
||||||
),
|
),
|
||||||
),
|
const Spacer(),
|
||||||
child: Text(l.storeFilterApply),
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
children: [
|
// card spans the full width so it reads as intentional
|
||||||
for (final d in _kDocs)
|
// rather than a lonely half-box with dead space beside it.
|
||||||
SizedBox(
|
final rows = <Widget>[];
|
||||||
width: cardWidth,
|
for (var i = 0; i < _kDocs.length; i += 2) {
|
||||||
child: _DocCard(entry: d),
|
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);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
_name.clear();
|
if (fromFields) {
|
||||||
_url.clear();
|
_name.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 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<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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue