Compare commits
No commits in common. "5d511dcd99ec3c4f187a38bfc8640c2a3b79a6fe" and "bb4d055a45e0bd9fc072f56b406feeea2f7a8876" have entirely different histories.
5d511dcd99
...
bb4d055a45
15 changed files with 323 additions and 743 deletions
|
|
@ -122,19 +122,6 @@ FriendlyError friendlyError(Object error, AppLocalizations l) {
|
||||||
FriendlyError? _matchHubPattern(String detail, AppLocalizations l) {
|
FriendlyError? _matchHubPattern(String detail, AppLocalizations l) {
|
||||||
if (detail.isEmpty) return null;
|
if (detail.isEmpty) return null;
|
||||||
|
|
||||||
// Module bundle download failed (bad / unhosted URL). The hub wraps
|
|
||||||
// these as "download failed: ..." and (older builds) mapped them to
|
|
||||||
// gRPC Unavailable — which the code-default would render as the
|
|
||||||
// misleading "hub not reachable". The hub is fine; show the real URL
|
|
||||||
// + status (carried in `detail`) under a clear headline.
|
|
||||||
if (detail.contains('download failed') || detail.contains('download error')) {
|
|
||||||
return FriendlyError(
|
|
||||||
headline: l.errModuleDownload,
|
|
||||||
detail: detail,
|
|
||||||
hint: l.errModuleDownloadHint,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Approval rejected: "step 'X' rejected by Y: reason"
|
// Approval rejected: "step 'X' rejected by Y: reason"
|
||||||
if (detail.contains('rejected by')) {
|
if (detail.contains('rejected by')) {
|
||||||
return FriendlyError(
|
return FriendlyError(
|
||||||
|
|
|
||||||
|
|
@ -786,7 +786,6 @@ class HubService {
|
||||||
kind: e.kind,
|
kind: e.kind,
|
||||||
provider: e.provider,
|
provider: e.provider,
|
||||||
sourceKind: e.sourceKind,
|
sourceKind: e.sourceKind,
|
||||||
source: e.source,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
@ -1794,12 +1793,6 @@ class StoreItem {
|
||||||
/// from `kind` for pre-0.12 hubs.
|
/// from `kind` for pre-0.12 hubs.
|
||||||
final String sourceKind;
|
final String sourceKind;
|
||||||
|
|
||||||
/// Name of the store this entry came from ("bundled" for the built-in
|
|
||||||
/// seed, otherwise the operator-added store's name). Distinct from
|
|
||||||
/// `sourceKind` (the transport) — this is the ORIGIN store, used to
|
|
||||||
/// label and group modules by where they came from.
|
|
||||||
final String source;
|
|
||||||
|
|
||||||
bool get isFederated => kind == 'federated';
|
bool get isFederated => kind == 'federated';
|
||||||
|
|
||||||
const StoreItem({
|
const StoreItem({
|
||||||
|
|
@ -1824,6 +1817,5 @@ class StoreItem {
|
||||||
required this.kind,
|
required this.kind,
|
||||||
required this.provider,
|
required this.provider,
|
||||||
this.sourceKind = '',
|
this.sourceKind = '',
|
||||||
this.source = '',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,15 +143,6 @@ class SystemActions {
|
||||||
return _runFai(['daemon', ...args]);
|
return _runFai(['daemon', ...args]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a `chain init ...` subcommand. Used by the guided-setup
|
|
||||||
/// wizard to preview a plan (`--answers <file>`) and apply it
|
|
||||||
/// (`--answers <file> --apply --force`).
|
|
||||||
static Future<({bool ok, String stdout, String stderr})> chainInit(
|
|
||||||
List<String> args,
|
|
||||||
) async {
|
|
||||||
return _runFai(['init', ...args]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run `chain update apply --channel <c>`. Long-running on a slow
|
/// Run `chain update apply --channel <c>`. Long-running on a slow
|
||||||
/// network — caller should show a spinner.
|
/// network — caller should show a spinner.
|
||||||
static Future<({bool ok, String stdout, String stderr})> chainUpdateApply(
|
static Future<({bool ok, String stdout, String stderr})> chainUpdateApply(
|
||||||
|
|
@ -251,10 +242,7 @@ class SystemActions {
|
||||||
}
|
}
|
||||||
|
|
||||||
final isWindows = Platform.isWindows;
|
final isWindows = Platform.isWindows;
|
||||||
// Post-rename the entry-point binary on PATH is `chain`; still
|
final fromPath = _whichFai(isWindows ? 'fai.exe' : 'fai');
|
||||||
// accept a legacy `fai` for installs that predate the rename.
|
|
||||||
final fromPath = _whichFai(isWindows ? 'chain.exe' : 'chain') ??
|
|
||||||
_whichFai(isWindows ? 'fai.exe' : 'fai');
|
|
||||||
if (fromPath != null) return fromPath;
|
if (fromPath != null) return fromPath;
|
||||||
|
|
||||||
final home = Platform.environment[isWindows ? 'USERPROFILE' : 'HOME'];
|
final home = Platform.environment[isWindows ? 'USERPROFILE' : 'HOME'];
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,5 @@
|
||||||
{
|
{
|
||||||
"@@locale": "de",
|
"@@locale": "de",
|
||||||
"errModuleDownload": "Modul-Download fehlgeschlagen",
|
|
||||||
"errModuleDownloadHint": "Die Bundle-URL des Moduls ist nicht erreichbar (Statuscode unten). Der Hub läuft — URL/Host korrigieren oder anderen Store wählen.",
|
|
||||||
"storeFromSource": "aus {store}",
|
|
||||||
"guidedSetupTitle": "Setup-Assistent",
|
|
||||||
"guidedSetupIntro": "Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.",
|
|
||||||
"guidedSetupScenario": "Szenario",
|
|
||||||
"guidedSetupIntent": "Aufgabe",
|
|
||||||
"guidedSetupTarget": "Wo läuft es?",
|
|
||||||
"guidedSetupApproval": "Menschlichen Freigabe-Schritt vor dem KI-Schritt verlangen",
|
|
||||||
"guidedSetupDataLocal": "Daten müssen lokal bleiben (air-gapped)",
|
|
||||||
"guidedSetupReviewHeading": "Diesen Plan wird Ch∆In anwenden:",
|
|
||||||
"guidedSetupApply": "Setup übernehmen",
|
|
||||||
"guidedSetupApplied": "Setup übernommen. Schließe mit den Schritten unten ab:",
|
|
||||||
"guidedSetupBack": "Zurück",
|
|
||||||
"guidedSetupNext": "Weiter",
|
|
||||||
"guidedSetupCancel": "Abbrechen",
|
|
||||||
"guidedSetupClose": "Schließen",
|
|
||||||
"appTitle": "Ch∆In Studio",
|
"appTitle": "Ch∆In Studio",
|
||||||
"navWelcome": "Willkommen",
|
"navWelcome": "Willkommen",
|
||||||
"welcomeHeroTitle": "Ch∆In Platform",
|
"welcomeHeroTitle": "Ch∆In Platform",
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,5 @@
|
||||||
{
|
{
|
||||||
"@@locale": "en",
|
"@@locale": "en",
|
||||||
"errModuleDownload": "Module download failed",
|
|
||||||
"errModuleDownloadHint": "The module's bundle URL is unreachable (status code below). The hub is running fine — fix the URL/host or choose another store.",
|
|
||||||
"storeFromSource": "from {store}",
|
|
||||||
"@storeFromSource": {
|
|
||||||
"placeholders": {
|
|
||||||
"store": { "type": "String" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"guidedSetupTitle": "Setup assistant",
|
|
||||||
"guidedSetupIntro": "Answer three questions and Ch∆In assembles a tailored config, module set and starter flow.",
|
|
||||||
"guidedSetupScenario": "Scenario",
|
|
||||||
"guidedSetupIntent": "Task",
|
|
||||||
"guidedSetupTarget": "Where will it run?",
|
|
||||||
"guidedSetupApproval": "Require a human approval step before the AI step",
|
|
||||||
"guidedSetupDataLocal": "Data must stay local (air-gapped posture)",
|
|
||||||
"guidedSetupReviewHeading": "This is the plan Ch∆In will apply:",
|
|
||||||
"guidedSetupApply": "Apply setup",
|
|
||||||
"guidedSetupApplied": "Setup applied. Finish with the steps below:",
|
|
||||||
"guidedSetupBack": "Back",
|
|
||||||
"guidedSetupNext": "Next",
|
|
||||||
"guidedSetupCancel": "Cancel",
|
|
||||||
"guidedSetupClose": "Close",
|
|
||||||
"appTitle": "Ch∆In Studio",
|
"appTitle": "Ch∆In Studio",
|
||||||
"@appTitle": {
|
"@appTitle": {
|
||||||
"description": "App-bar title in the OS task switcher."
|
"description": "App-bar title in the OS task switcher."
|
||||||
|
|
|
||||||
|
|
@ -98,108 +98,6 @@ abstract class AppLocalizations {
|
||||||
Locale('en'),
|
Locale('en'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// No description provided for @errModuleDownload.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Module download failed'**
|
|
||||||
String get errModuleDownload;
|
|
||||||
|
|
||||||
/// No description provided for @errModuleDownloadHint.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'The module\'s bundle URL is unreachable (status code below). The hub is running fine — fix the URL/host or choose another store.'**
|
|
||||||
String get errModuleDownloadHint;
|
|
||||||
|
|
||||||
/// No description provided for @storeFromSource.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'from {store}'**
|
|
||||||
String storeFromSource(String store);
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupTitle.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Setup assistant'**
|
|
||||||
String get guidedSetupTitle;
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupIntro.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Answer three questions and Ch∆In assembles a tailored config, module set and starter flow.'**
|
|
||||||
String get guidedSetupIntro;
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupScenario.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Scenario'**
|
|
||||||
String get guidedSetupScenario;
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupIntent.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Task'**
|
|
||||||
String get guidedSetupIntent;
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupTarget.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Where will it run?'**
|
|
||||||
String get guidedSetupTarget;
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupApproval.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Require a human approval step before the AI step'**
|
|
||||||
String get guidedSetupApproval;
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupDataLocal.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Data must stay local (air-gapped posture)'**
|
|
||||||
String get guidedSetupDataLocal;
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupReviewHeading.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'This is the plan Ch∆In will apply:'**
|
|
||||||
String get guidedSetupReviewHeading;
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupApply.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Apply setup'**
|
|
||||||
String get guidedSetupApply;
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupApplied.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Setup applied. Finish with the steps below:'**
|
|
||||||
String get guidedSetupApplied;
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupBack.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Back'**
|
|
||||||
String get guidedSetupBack;
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupNext.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Next'**
|
|
||||||
String get guidedSetupNext;
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupCancel.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Cancel'**
|
|
||||||
String get guidedSetupCancel;
|
|
||||||
|
|
||||||
/// No description provided for @guidedSetupClose.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Close'**
|
|
||||||
String get guidedSetupClose;
|
|
||||||
|
|
||||||
/// App-bar title in the OS task switcher.
|
/// App-bar title in the OS task switcher.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -8,63 +8,6 @@ import 'app_localizations.dart';
|
||||||
class AppLocalizationsDe extends AppLocalizations {
|
class AppLocalizationsDe extends AppLocalizations {
|
||||||
AppLocalizationsDe([String locale = 'de']) : super(locale);
|
AppLocalizationsDe([String locale = 'de']) : super(locale);
|
||||||
|
|
||||||
@override
|
|
||||||
String get errModuleDownload => 'Modul-Download fehlgeschlagen';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errModuleDownloadHint =>
|
|
||||||
'Die Bundle-URL des Moduls ist nicht erreichbar (Statuscode unten). Der Hub läuft — URL/Host korrigieren oder anderen Store wählen.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String storeFromSource(String store) {
|
|
||||||
return 'aus $store';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupTitle => 'Setup-Assistent';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupIntro =>
|
|
||||||
'Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupScenario => 'Szenario';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupIntent => 'Aufgabe';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupTarget => 'Wo läuft es?';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupApproval =>
|
|
||||||
'Menschlichen Freigabe-Schritt vor dem KI-Schritt verlangen';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupDataLocal => 'Daten müssen lokal bleiben (air-gapped)';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupReviewHeading => 'Diesen Plan wird Ch∆In anwenden:';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupApply => 'Setup übernehmen';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupApplied =>
|
|
||||||
'Setup übernommen. Schließe mit den Schritten unten ab:';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupBack => 'Zurück';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupNext => 'Weiter';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupCancel => 'Abbrechen';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupClose => 'Schließen';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appTitle => 'Ch∆In Studio';
|
String get appTitle => 'Ch∆In Studio';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,64 +8,6 @@ import 'app_localizations.dart';
|
||||||
class AppLocalizationsEn extends AppLocalizations {
|
class AppLocalizationsEn extends AppLocalizations {
|
||||||
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||||
|
|
||||||
@override
|
|
||||||
String get errModuleDownload => 'Module download failed';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get errModuleDownloadHint =>
|
|
||||||
'The module\'s bundle URL is unreachable (status code below). The hub is running fine — fix the URL/host or choose another store.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String storeFromSource(String store) {
|
|
||||||
return 'from $store';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupTitle => 'Setup assistant';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupIntro =>
|
|
||||||
'Answer three questions and Ch∆In assembles a tailored config, module set and starter flow.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupScenario => 'Scenario';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupIntent => 'Task';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupTarget => 'Where will it run?';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupApproval =>
|
|
||||||
'Require a human approval step before the AI step';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupDataLocal =>
|
|
||||||
'Data must stay local (air-gapped posture)';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupReviewHeading => 'This is the plan Ch∆In will apply:';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupApply => 'Apply setup';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupApplied =>
|
|
||||||
'Setup applied. Finish with the steps below:';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupBack => 'Back';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupNext => 'Next';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupCancel => 'Cancel';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get guidedSetupClose => 'Close';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appTitle => 'Ch∆In Studio';
|
String get appTitle => 'Ch∆In Studio';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,18 +60,11 @@ class StudioApp extends StatefulWidget {
|
||||||
/// ChainTheme.
|
/// ChainTheme.
|
||||||
final String? initialThemePlugin;
|
final String? initialThemePlugin;
|
||||||
|
|
||||||
/// Test-only: start the sidebar expanded (no hover). Lets a widget
|
|
||||||
/// test assert destination-icon Y stability across collapsed vs
|
|
||||||
/// expanded without a hover gesture (which trips RenderFlex overflow
|
|
||||||
/// mid-transition). Always false in production.
|
|
||||||
final bool startSidebarExpanded;
|
|
||||||
|
|
||||||
const StudioApp({
|
const StudioApp({
|
||||||
super.key,
|
super.key,
|
||||||
required this.initialThemeMode,
|
required this.initialThemeMode,
|
||||||
required this.initialLocale,
|
required this.initialLocale,
|
||||||
this.initialThemePlugin,
|
this.initialThemePlugin,
|
||||||
this.startSidebarExpanded = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Lookup helper so descendants can flip the theme without
|
/// Lookup helper so descendants can flip the theme without
|
||||||
|
|
@ -233,9 +226,7 @@ class StudioAppState extends State<StudioApp> {
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localizationsDelegates:
|
localizationsDelegates:
|
||||||
AppLocalizations.localizationsDelegates,
|
AppLocalizations.localizationsDelegates,
|
||||||
home: StudioShell(
|
home: const StudioShell(),
|
||||||
startSidebarExpanded: widget.startSidebarExpanded,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -246,10 +237,7 @@ class StudioAppState extends State<StudioApp> {
|
||||||
}
|
}
|
||||||
|
|
||||||
class StudioShell extends StatefulWidget {
|
class StudioShell extends StatefulWidget {
|
||||||
/// Test-only: forward to the sidebar so it starts expanded.
|
const StudioShell({super.key});
|
||||||
final bool startSidebarExpanded;
|
|
||||||
|
|
||||||
const StudioShell({super.key, this.startSidebarExpanded = false});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StudioShell> createState() => StudioShellState();
|
State<StudioShell> createState() => StudioShellState();
|
||||||
|
|
@ -535,7 +523,6 @@ class StudioShellState extends State<StudioShell> {
|
||||||
endpointLabel: HubService.instance.endpointLabel,
|
endpointLabel: HubService.instance.endpointLabel,
|
||||||
activeChannel: _activeChannel,
|
activeChannel: _activeChannel,
|
||||||
pendingApprovals: _pendingApprovals,
|
pendingApprovals: _pendingApprovals,
|
||||||
forceExpanded: widget.startSidebarExpanded,
|
|
||||||
),
|
),
|
||||||
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -662,11 +649,6 @@ class _Sidebar extends StatefulWidget {
|
||||||
/// without polling the page.
|
/// without polling the page.
|
||||||
final int pendingApprovals;
|
final int pendingApprovals;
|
||||||
|
|
||||||
/// Test-only: start fully expanded (controller at 1.0) and disable
|
|
||||||
/// hover, so a widget test can measure the expanded layout without a
|
|
||||||
/// hover gesture. Always false in production.
|
|
||||||
final bool forceExpanded;
|
|
||||||
|
|
||||||
const _Sidebar({
|
const _Sidebar({
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onSelect,
|
required this.onSelect,
|
||||||
|
|
@ -675,7 +657,6 @@ class _Sidebar extends StatefulWidget {
|
||||||
required this.endpointLabel,
|
required this.endpointLabel,
|
||||||
required this.activeChannel,
|
required this.activeChannel,
|
||||||
this.pendingApprovals = 0,
|
this.pendingApprovals = 0,
|
||||||
this.forceExpanded = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -701,10 +682,6 @@ class _SidebarState extends State<_Sidebar>
|
||||||
static const double _channelRowH = 28;
|
static const double _channelRowH = 28;
|
||||||
static const double _rowGap = 8;
|
static const double _rowGap = 8;
|
||||||
late final AnimationController _ctrl;
|
late final AnimationController _ctrl;
|
||||||
// True while the channel-switch menu is open — suppresses the
|
|
||||||
// hover-exit collapse so the rail stays expanded and the menu stays
|
|
||||||
// aligned to the pill (otherwise it floats at the pill's old x).
|
|
||||||
bool _menuOpen = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -717,7 +694,7 @@ class _SidebarState extends State<_Sidebar>
|
||||||
// the rail feels crisp rather than sluggish.
|
// the rail feels crisp rather than sluggish.
|
||||||
duration: ChainMotion.slow,
|
duration: ChainMotion.slow,
|
||||||
reverseDuration: ChainMotion.base,
|
reverseDuration: ChainMotion.base,
|
||||||
value: widget.forceExpanded ? 1 : 0,
|
value: 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -763,10 +740,8 @@ class _SidebarState extends State<_Sidebar>
|
||||||
final hasChannel =
|
final hasChannel =
|
||||||
widget.activeChannel != null && widget.activeChannel!.isNotEmpty;
|
widget.activeChannel != null && widget.activeChannel!.isNotEmpty;
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
onEnter: widget.forceExpanded ? null : (_) => _ctrl.forward(),
|
onEnter: (_) => _ctrl.forward(),
|
||||||
onExit: (widget.forceExpanded || _menuOpen)
|
onExit: (_) => _ctrl.reverse(),
|
||||||
? null
|
|
||||||
: (_) => _ctrl.reverse(),
|
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation: _ctrl,
|
animation: _ctrl,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
|
|
@ -849,18 +824,7 @@ class _SidebarState extends State<_Sidebar>
|
||||||
channel: widget.activeChannel!,
|
channel: widget.activeChannel!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
label: _ChannelPill(
|
label: _ChannelPill(channel: widget.activeChannel!),
|
||||||
channel: widget.activeChannel!,
|
|
||||||
onMenuOpen: () {
|
|
||||||
setState(() => _menuOpen = true);
|
|
||||||
_ctrl.forward();
|
|
||||||
},
|
|
||||||
onMenuClose: () {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _menuOpen = false);
|
|
||||||
_ctrl.reverse();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
t: t,
|
t: t,
|
||||||
labelsInteractive: labelsInteractive,
|
labelsInteractive: labelsInteractive,
|
||||||
)
|
)
|
||||||
|
|
@ -873,7 +837,6 @@ class _SidebarState extends State<_Sidebar>
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < widget.pages.length; i++)
|
for (var i = 0; i < widget.pages.length; i++)
|
||||||
_SidebarItem(
|
_SidebarItem(
|
||||||
key: ValueKey('sidebar-item-${widget.pages[i].id}'),
|
|
||||||
page: widget.pages[i],
|
page: widget.pages[i],
|
||||||
selected: i == widget.selectedIndex,
|
selected: i == widget.selectedIndex,
|
||||||
t: t,
|
t: t,
|
||||||
|
|
@ -1157,17 +1120,7 @@ class _ChannelMenuItem extends StatelessWidget {
|
||||||
/// unmistakeable.
|
/// unmistakeable.
|
||||||
class _ChannelPill extends StatelessWidget {
|
class _ChannelPill extends StatelessWidget {
|
||||||
final String channel;
|
final String channel;
|
||||||
|
const _ChannelPill({required this.channel});
|
||||||
/// Fired when the switch menu opens / closes so the parent sidebar can
|
|
||||||
/// stay expanded while it's up — otherwise the hover-exit collapses the
|
|
||||||
/// rail and the menu floats at the pill's old (expanded) position.
|
|
||||||
final VoidCallback? onMenuOpen;
|
|
||||||
final VoidCallback? onMenuClose;
|
|
||||||
const _ChannelPill({
|
|
||||||
required this.channel,
|
|
||||||
this.onMenuOpen,
|
|
||||||
this.onMenuClose,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -1181,11 +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: () async {
|
onTap: () => _showChannelSwitchMenu(context, channel),
|
||||||
onMenuOpen?.call();
|
|
||||||
await _showChannelSwitchMenu(context, channel);
|
|
||||||
onMenuClose?.call();
|
|
||||||
},
|
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: ChainSpace.sm,
|
horizontal: ChainSpace.sm,
|
||||||
|
|
@ -1393,7 +1342,6 @@ class _SidebarItem extends StatefulWidget {
|
||||||
final int? badge;
|
final int? badge;
|
||||||
|
|
||||||
const _SidebarItem({
|
const _SidebarItem({
|
||||||
super.key,
|
|
||||||
required this.page,
|
required this.page,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
required this.t,
|
required this.t,
|
||||||
|
|
|
||||||
305
lib/pages/modules.dart
Normal file
305
lib/pages/modules.dart
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../data/hub.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../theme/theme.dart';
|
||||||
|
import '../theme/tokens.dart';
|
||||||
|
import '../widgets/widgets.dart';
|
||||||
|
|
||||||
|
class ModulesPage extends StatefulWidget {
|
||||||
|
const ModulesPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ModulesPage> createState() => _ModulesPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModulesPageState extends State<ModulesPage> {
|
||||||
|
late Future<List<ModuleSummary>> _future;
|
||||||
|
late Future<List<AuditEvent>> _historyFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = HubService.instance.listModules();
|
||||||
|
_historyFuture = _loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AuditEvent>> _loadHistory() {
|
||||||
|
return HubService.instance.recentEvents(
|
||||||
|
limit: 10,
|
||||||
|
types: const ['module.installed', 'module.uninstalled'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refresh() => setState(() {
|
||||||
|
_future = HubService.instance.listModules();
|
||||||
|
_historyFuture = _loadHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(AppLocalizations.of(context)!.modulesTitle),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh, size: 18),
|
||||||
|
tooltip: AppLocalizations.of(context)!.modulesReloadTooltip,
|
||||||
|
onPressed: _refresh,
|
||||||
|
),
|
||||||
|
const SizedBox(width: ChainSpace.sm),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: FutureBuilder<List<ModuleSummary>>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return ChainEmptyState(
|
||||||
|
icon: Icons.cloud_off_outlined,
|
||||||
|
iconColor: Theme.of(context).colorScheme.error,
|
||||||
|
title: l.hubUnreachable,
|
||||||
|
hint: l.hubUnreachableHint,
|
||||||
|
action: FilledButton.tonal(
|
||||||
|
onPressed: _refresh,
|
||||||
|
child: Text(l.buttonRetry),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final modules = snapshot.data ?? [];
|
||||||
|
if (modules.isEmpty) {
|
||||||
|
return ChainEmptyState(
|
||||||
|
icon: Icons.extension_outlined,
|
||||||
|
title: l.modulesNoneTitle,
|
||||||
|
hint: l.modulesNoneHint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(ChainSpace.xl),
|
||||||
|
children: [
|
||||||
|
_RecentActivityPanel(future: _historyFuture),
|
||||||
|
const SizedBox(height: ChainSpace.md),
|
||||||
|
for (var i = 0; i < modules.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(height: ChainSpace.md),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final uninstalled = await ChainModuleSheet.show(
|
||||||
|
context,
|
||||||
|
modules[i].name,
|
||||||
|
);
|
||||||
|
if (uninstalled) _refresh();
|
||||||
|
},
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: _ModuleCard(module: modules[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModuleCard extends StatelessWidget {
|
||||||
|
final ModuleSummary module;
|
||||||
|
|
||||||
|
const _ModuleCard({required this.module});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return ChainCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
module.name,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: ChainSpace.sm),
|
||||||
|
ChainPill(
|
||||||
|
label: 'v${module.version}',
|
||||||
|
tone: ChainPillTone.neutral,
|
||||||
|
monospace: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: ChainSpace.md),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 110,
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(context)!.modulesCapabilitiesLabel,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Wrap(
|
||||||
|
spacing: ChainSpace.xs,
|
||||||
|
runSpacing: ChainSpace.xs,
|
||||||
|
children: module.capabilities
|
||||||
|
.map(
|
||||||
|
(c) => ChainPill(
|
||||||
|
label: c,
|
||||||
|
tone: ChainPillTone.accent,
|
||||||
|
monospace: true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact "Recent activity" header listing the last few
|
||||||
|
/// install / uninstall events from the audit log. Lets
|
||||||
|
/// operators trace "wait, when did that module appear?"
|
||||||
|
/// without leaving the Modules page. Hidden when the audit
|
||||||
|
/// log has no relevant events yet.
|
||||||
|
class _RecentActivityPanel extends StatelessWidget {
|
||||||
|
final Future<List<AuditEvent>> future;
|
||||||
|
const _RecentActivityPanel({required this.future});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return FutureBuilder<List<AuditEvent>>(
|
||||||
|
future: future,
|
||||||
|
builder: (context, snap) {
|
||||||
|
final events = snap.data ?? const <AuditEvent>[];
|
||||||
|
if (events.isEmpty) return const SizedBox.shrink();
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: ChainSpace.md,
|
||||||
|
vertical: ChainSpace.sm,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.history,
|
||||||
|
size: 14,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context)!.modulesRecentActivity,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: ChainSpace.sm),
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.modulesRecentActivityHint(events.length),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: ChainSpace.xs),
|
||||||
|
for (final e in events) _ActivityRow(event: e),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActivityRow extends StatelessWidget {
|
||||||
|
final AuditEvent event;
|
||||||
|
const _ActivityRow({required this.event});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final installed = event.type == 'module.installed';
|
||||||
|
final accent = installed ? ChainColors.success : ChainColors.warning;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
installed ? Icons.add_circle_outline : Icons.remove_circle_outline,
|
||||||
|
size: 14,
|
||||||
|
color: accent,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
SizedBox(
|
||||||
|
width: 150,
|
||||||
|
child: Text(
|
||||||
|
_formatTimestamp(event.timestamp.toLocal()),
|
||||||
|
style: ChainTheme.mono(
|
||||||
|
size: 10,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${installed ? AppLocalizations.of(context)!.modulesActivityInstalled : AppLocalizations.of(context)!.modulesActivityUninstalled} '
|
||||||
|
'${event.moduleName ?? "(unknown)"}'
|
||||||
|
'${event.moduleVersion != null ? " v${event.moduleVersion}" : ""}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locale-unambiguous timestamp shared by the activity rows.
|
||||||
|
/// Same-day events get HH:mm:ss; older events get the full
|
||||||
|
/// YYYY-MM-DD HH:mm:ss so an operator never wonders what day
|
||||||
|
/// "21:25" was.
|
||||||
|
String _formatTimestamp(DateTime local) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final hh = local.hour.toString().padLeft(2, '0');
|
||||||
|
final mm = local.minute.toString().padLeft(2, '0');
|
||||||
|
final ss = local.second.toString().padLeft(2, '0');
|
||||||
|
final time = '$hh:$mm:$ss';
|
||||||
|
final sameDay =
|
||||||
|
local.year == now.year &&
|
||||||
|
local.month == now.month &&
|
||||||
|
local.day == now.day;
|
||||||
|
if (sameDay) return time;
|
||||||
|
final mo = local.month.toString().padLeft(2, '0');
|
||||||
|
final dd = local.day.toString().padLeft(2, '0');
|
||||||
|
return '${local.year}-$mo-$dd $time';
|
||||||
|
}
|
||||||
|
|
@ -1899,10 +1899,16 @@ class _StoreCardState extends State<_StoreCard> {
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_ModuleIcon(
|
CircleAvatar(
|
||||||
iconUrl: item.iconUrl,
|
|
||||||
category: item.category,
|
|
||||||
radius: 18,
|
radius: 18,
|
||||||
|
backgroundColor: theme.colorScheme.primary.withValues(
|
||||||
|
alpha: 0.12,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_iconForCategory(item.category),
|
||||||
|
size: 18,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.sm),
|
const SizedBox(width: ChainSpace.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -1930,19 +1936,6 @@ class _StoreCardState extends State<_StoreCard> {
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Origin store ("from <store>") — only for
|
|
||||||
// operator-added stores; the built-in seed
|
|
||||||
// ("bundled") needs no label.
|
|
||||||
if (item.source.isNotEmpty &&
|
|
||||||
item.source != 'bundled')
|
|
||||||
Text(
|
|
||||||
l.storeFromSource(item.source),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import '../l10n/app_localizations.dart';
|
||||||
import '../main.dart' show StudioShellState;
|
import '../main.dart' show StudioShellState;
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
import '../widgets/guided_setup_dialog.dart';
|
|
||||||
import '../widgets/widgets.dart';
|
import '../widgets/widgets.dart';
|
||||||
|
|
||||||
class WelcomePage extends StatelessWidget {
|
class WelcomePage extends StatelessWidget {
|
||||||
|
|
@ -60,18 +59,6 @@ class WelcomePage extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const _Hero(),
|
const _Hero(),
|
||||||
const SizedBox(height: ChainSpace.md),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
// High-emphasis: the guided setup is the fastest path
|
|
||||||
// to a working first run, so lead with a filled button
|
|
||||||
// rather than a low-key outlined one.
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed: () => GuidedSetupDialog.show(context),
|
|
||||||
icon: const Icon(Icons.auto_fix_high),
|
|
||||||
label: Text(AppLocalizations.of(context)!.guidedSetupTitle),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: ChainSpace.xxl),
|
const SizedBox(height: ChainSpace.xxl),
|
||||||
// When the hub is down the onboarding checklist
|
// When the hub is down the onboarding checklist
|
||||||
// would render dead (all-unchecked, nothing to
|
// would render dead (all-unchecked, nothing to
|
||||||
|
|
|
||||||
|
|
@ -1,257 +0,0 @@
|
||||||
// Guided-setup wizard. Collects scenario / intent / target (plus two
|
|
||||||
// adaptive toggles), then calls `chain init --answers` to PREVIEW the
|
|
||||||
// assembled plan and `--apply` to write the config. All the policy lives
|
|
||||||
// in the Rust deterministic engine (chain_core::guided_setup) — this is
|
|
||||||
// a thin UI over the CLI, so the logic never diverges.
|
|
||||||
//
|
|
||||||
// NOTE: the dropdown option labels are humanised from the canonical
|
|
||||||
// kebab-case enum values (trying-out → "Trying Out"); localising those
|
|
||||||
// per option is a follow-up. The structural UI + the applied plan
|
|
||||||
// (rendered from the CLI's localisable output) are the meaningful parts.
|
|
||||||
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../data/error_presentation.dart';
|
|
||||||
import '../data/system_actions.dart';
|
|
||||||
import '../l10n/app_localizations.dart';
|
|
||||||
|
|
||||||
class GuidedSetupDialog extends StatefulWidget {
|
|
||||||
const GuidedSetupDialog({super.key});
|
|
||||||
|
|
||||||
/// Launch the wizard as a modal dialog.
|
|
||||||
static Future<void> show(BuildContext context) => showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => const GuidedSetupDialog(),
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<GuidedSetupDialog> createState() => _GuidedSetupDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
|
||||||
// Canonical SetupAnswers enum values (serde kebab-case in Rust).
|
|
||||||
static const _scenarios = <String>[
|
|
||||||
'trying-out',
|
|
||||||
'team-hub',
|
|
||||||
'regulated-production',
|
|
||||||
'building-modules',
|
|
||||||
];
|
|
||||||
static const _intents = <String>[
|
|
||||||
'hello-world',
|
|
||||||
'extract-text',
|
|
||||||
'extract-summarize',
|
|
||||||
'classify-documents',
|
|
||||||
'build-module',
|
|
||||||
];
|
|
||||||
static const _targets = <String>[
|
|
||||||
'this-laptop',
|
|
||||||
'home-server',
|
|
||||||
'air-gapped-server',
|
|
||||||
'container',
|
|
||||||
];
|
|
||||||
|
|
||||||
int _step = 0; // 0 = answers, 1 = review/apply
|
|
||||||
String _scenario = 'trying-out';
|
|
||||||
String _intent = 'extract-summarize';
|
|
||||||
String _target = 'this-laptop';
|
|
||||||
bool _requireApproval = false;
|
|
||||||
bool _dataLocal = false;
|
|
||||||
bool _busy = false;
|
|
||||||
String? _preview;
|
|
||||||
String? _applied;
|
|
||||||
|
|
||||||
String _humanize(String kebab) => kebab
|
|
||||||
.split('-')
|
|
||||||
.map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}')
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
String _answersYaml() => 'scenario: $_scenario\n'
|
|
||||||
'intent: $_intent\n'
|
|
||||||
'target: $_target\n'
|
|
||||||
'require_approval: $_requireApproval\n'
|
|
||||||
'data_must_stay_local: $_dataLocal\n';
|
|
||||||
|
|
||||||
Future<String> _writeAnswers() async {
|
|
||||||
final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml');
|
|
||||||
await f.writeAsString(_answersYaml());
|
|
||||||
return f.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _goReview() async {
|
|
||||||
setState(() {
|
|
||||||
_busy = true;
|
|
||||||
_preview = null;
|
|
||||||
});
|
|
||||||
final path = await _writeAnswers();
|
|
||||||
final r = await SystemActions.chainInit(['--answers', path]);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _busy = false);
|
|
||||||
if (r.ok) {
|
|
||||||
setState(() {
|
|
||||||
_preview = r.stdout.trim();
|
|
||||||
_step = 1;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
showFaiProcessError(context, 'chain init --answers', r.stdout, r.stderr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _apply() async {
|
|
||||||
setState(() => _busy = true);
|
|
||||||
final path = await _writeAnswers();
|
|
||||||
final r = await SystemActions.chainInit(
|
|
||||||
['--answers', path, '--apply', '--force'],
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _busy = false);
|
|
||||||
if (r.ok) {
|
|
||||||
setState(() => _applied = r.stdout.trim());
|
|
||||||
} else {
|
|
||||||
showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l = AppLocalizations.of(context)!;
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text(l.guidedSetupTitle),
|
|
||||||
content: SizedBox(
|
|
||||||
width: 460,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: _step == 0 ? _answersStep(l) : _reviewStep(l),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: _actions(l),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _answersStep(AppLocalizations l) {
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(l.guidedSetupIntro),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_dropdown(l.guidedSetupScenario, _scenarios, _scenario,
|
|
||||||
(v) => setState(() => _scenario = v)),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_dropdown(l.guidedSetupIntent, _intents, _intent,
|
|
||||||
(v) => setState(() => _intent = v)),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_dropdown(l.guidedSetupTarget, _targets, _target,
|
|
||||||
(v) => setState(() => _target = v)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SwitchListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(l.guidedSetupApproval),
|
|
||||||
value: _requireApproval,
|
|
||||||
onChanged: (v) => setState(() => _requireApproval = v),
|
|
||||||
),
|
|
||||||
SwitchListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(l.guidedSetupDataLocal),
|
|
||||||
value: _dataLocal,
|
|
||||||
onChanged: (v) => setState(() => _dataLocal = v),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _dropdown(
|
|
||||||
String label,
|
|
||||||
List<String> items,
|
|
||||||
String value,
|
|
||||||
ValueChanged<String> onChanged,
|
|
||||||
) {
|
|
||||||
return InputDecorator(
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: label,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
child: DropdownButtonHideUnderline(
|
|
||||||
child: DropdownButton<String>(
|
|
||||||
isExpanded: true,
|
|
||||||
value: value,
|
|
||||||
items: [
|
|
||||||
for (final i in items)
|
|
||||||
DropdownMenuItem<String>(value: i, child: Text(_humanize(i))),
|
|
||||||
],
|
|
||||||
onChanged: (v) {
|
|
||||||
if (v != null) onChanged(v);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _reviewStep(AppLocalizations l) {
|
|
||||||
final applied = _applied;
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(applied != null
|
|
||||||
? l.guidedSetupApplied
|
|
||||||
: l.guidedSetupReviewHeading),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
// SelectableText keeps the plan copyable (project rule:
|
|
||||||
// anything an operator might want to keep is selectable).
|
|
||||||
child: SelectableText(
|
|
||||||
applied ?? _preview ?? '',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Widget> _actions(AppLocalizations l) {
|
|
||||||
if (_busy) {
|
|
||||||
return const [
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.all(12),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (_applied != null) {
|
|
||||||
return [
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: Text(l.guidedSetupClose),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (_step == 0) {
|
|
||||||
return [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: Text(l.guidedSetupCancel),
|
|
||||||
),
|
|
||||||
FilledButton(onPressed: _goReview, child: Text(l.guidedSetupNext)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => setState(() => _step = 0),
|
|
||||||
child: Text(l.guidedSetupBack),
|
|
||||||
),
|
|
||||||
FilledButton(onPressed: _apply, child: Text(l.guidedSetupApply)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
// Smoke test for the guided-setup wizard: it mounts and renders its
|
|
||||||
// first step (three choice dropdowns) without touching the CLI. The
|
|
||||||
// `chain init` shell-out only happens on Next/Apply, so mounting is
|
|
||||||
// side-effect-free and safe to assert in a widget test.
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:chain_studio/l10n/app_localizations.dart';
|
|
||||||
import 'package:chain_studio/widgets/guided_setup_dialog.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('guided setup wizard mounts and shows the first step',
|
|
||||||
(tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
locale: const Locale('en'),
|
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
|
||||||
home: const Scaffold(body: GuidedSetupDialog()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
// Title from l10n + the three scenario/intent/target dropdowns.
|
|
||||||
expect(find.text('Setup assistant'), findsWidgets);
|
|
||||||
expect(find.byType(DropdownButton<String>), findsNWidgets(3));
|
|
||||||
// The "Next" action is present (advances to the review/apply step).
|
|
||||||
expect(find.widgetWithText(FilledButton, 'Next'), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
// Locks the sidebar invariant: destination icons sit at the same Y
|
|
||||||
// whether the rail is collapsed or expanded. The header rows above the
|
|
||||||
// destinations list have FIXED pixel heights (see _SidebarState), so
|
|
||||||
// expanding the rail (a width-only change) must not shift any
|
|
||||||
// destination vertically. This was a recurring regression source; this
|
|
||||||
// test pins it.
|
|
||||||
//
|
|
||||||
// Expansion is driven via StudioApp(startSidebarExpanded: true), NOT a
|
|
||||||
// hover gesture — hover mid-transition trips RenderFlex overflow in
|
|
||||||
// widget tests (see widget_test.dart). With the flag the rail's
|
|
||||||
// animation controller starts at value 1.0 (fully expanded), so no
|
|
||||||
// animation needs to settle.
|
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:chain_studio/data/hub.dart';
|
|
||||||
import 'package:chain_studio/main.dart';
|
|
||||||
|
|
||||||
const _destinations = <String>[
|
|
||||||
'welcome',
|
|
||||||
'store',
|
|
||||||
'doctor',
|
|
||||||
'flows',
|
|
||||||
'audit',
|
|
||||||
'approvals',
|
|
||||||
'federation',
|
|
||||||
];
|
|
||||||
|
|
||||||
Map<String, double> _destinationYs(WidgetTester tester) {
|
|
||||||
final ys = <String, double>{};
|
|
||||||
for (final id in _destinations) {
|
|
||||||
final finder = find.byKey(ValueKey('sidebar-item-$id'));
|
|
||||||
expect(finder, findsOneWidget, reason: 'destination "$id" must render');
|
|
||||||
ys[id] = tester.getTopLeft(finder).dy;
|
|
||||||
}
|
|
||||||
return ys;
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets(
|
|
||||||
'sidebar destination Y positions are stable across rail expansion',
|
|
||||||
(tester) async {
|
|
||||||
// Collapsed (default).
|
|
||||||
await tester.pumpWidget(
|
|
||||||
const StudioApp(
|
|
||||||
initialThemeMode: ThemeModeValue.system,
|
|
||||||
initialLocale: Locale('en'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// pump (not pumpAndSettle): the app has long-lived timers/animations
|
|
||||||
// that never "settle"; one frame is enough to lay the sidebar out.
|
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
|
||||||
final collapsed = _destinationYs(tester);
|
|
||||||
|
|
||||||
// Expanded: controller starts at 1.0 via the flag, hover disabled.
|
|
||||||
await tester.pumpWidget(
|
|
||||||
const StudioApp(
|
|
||||||
initialThemeMode: ThemeModeValue.system,
|
|
||||||
initialLocale: Locale('en'),
|
|
||||||
startSidebarExpanded: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
|
||||||
final expanded = _destinationYs(tester);
|
|
||||||
|
|
||||||
for (final id in _destinations) {
|
|
||||||
expect(
|
|
||||||
expanded[id],
|
|
||||||
closeTo(collapsed[id]!, 0.5),
|
|
||||||
reason: 'destination "$id" Y shifted on expansion '
|
|
||||||
'(${collapsed[id]} -> ${expanded[id]}) — the rows above the '
|
|
||||||
'destinations list must keep fixed heights',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue