From d66f46f133daad2b6caf4a95368d9add5d8f3a83 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 19 Jun 2026 16:33:54 +0200 Subject: [PATCH 1/8] fix: resolve 'chain' binary on PATH (legacy 'fai' fallback) Studio's Start-hub looked for 'fai' on PATH; post-rename the entry binary is 'chain', so a fresh install failed to start the daemon. Signed-off-by: flemming-it --- lib/data/system_actions.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index 6cd8149..24598a5 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -242,7 +242,10 @@ class SystemActions { } final isWindows = Platform.isWindows; - final fromPath = _whichFai(isWindows ? 'fai.exe' : 'fai'); + // Post-rename the entry-point binary on PATH is `chain`; still + // 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; final home = Platform.environment[isWindows ? 'USERPROFILE' : 'HOME']; From 36a8252f67288150960412cdd923c3adf5008ca8 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 19 Jun 2026 21:30:02 +0200 Subject: [PATCH 2/8] test(studio): lock sidebar destination Y-stability on rail expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a widget test asserting each destination icon keeps its Y position whether the rail is collapsed or expanded — a recurring regression. To drive expansion without a hover gesture (which trips RenderFlex overflow mid-transition), a test-only startSidebarExpanded flag threads StudioApp -> StudioShell -> _Sidebar (controller starts at 1.0, hover disabled); _SidebarItems get stable ValueKeys. analyze clean; new test and the existing smoke test pass. Signed-off-by: flemming-it --- lib/main.dart | 31 ++++++++++++++--- test/sidebar_test.dart | 77 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 test/sidebar_test.dart diff --git a/lib/main.dart b/lib/main.dart index e7da38c..75aaa20 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -60,11 +60,18 @@ class StudioApp extends StatefulWidget { /// ChainTheme. 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({ super.key, required this.initialThemeMode, required this.initialLocale, this.initialThemePlugin, + this.startSidebarExpanded = false, }); /// Lookup helper so descendants can flip the theme without @@ -226,7 +233,9 @@ class StudioAppState extends State { supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, - home: const StudioShell(), + home: StudioShell( + startSidebarExpanded: widget.startSidebarExpanded, + ), ); }, ), @@ -237,7 +246,10 @@ class StudioAppState extends State { } class StudioShell extends StatefulWidget { - const StudioShell({super.key}); + /// Test-only: forward to the sidebar so it starts expanded. + final bool startSidebarExpanded; + + const StudioShell({super.key, this.startSidebarExpanded = false}); @override State createState() => StudioShellState(); @@ -523,6 +535,7 @@ class StudioShellState extends State { endpointLabel: HubService.instance.endpointLabel, activeChannel: _activeChannel, pendingApprovals: _pendingApprovals, + forceExpanded: widget.startSidebarExpanded, ), Container(width: 1, color: theme.colorScheme.outlineVariant), Expanded( @@ -649,6 +662,11 @@ class _Sidebar extends StatefulWidget { /// without polling the page. 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({ required this.selectedIndex, required this.onSelect, @@ -657,6 +675,7 @@ class _Sidebar extends StatefulWidget { required this.endpointLabel, required this.activeChannel, this.pendingApprovals = 0, + this.forceExpanded = false, }); @override @@ -694,7 +713,7 @@ class _SidebarState extends State<_Sidebar> // the rail feels crisp rather than sluggish. duration: ChainMotion.slow, reverseDuration: ChainMotion.base, - value: 0, + value: widget.forceExpanded ? 1 : 0, ); } @@ -740,8 +759,8 @@ class _SidebarState extends State<_Sidebar> final hasChannel = widget.activeChannel != null && widget.activeChannel!.isNotEmpty; return MouseRegion( - onEnter: (_) => _ctrl.forward(), - onExit: (_) => _ctrl.reverse(), + onEnter: widget.forceExpanded ? null : (_) => _ctrl.forward(), + onExit: widget.forceExpanded ? null : (_) => _ctrl.reverse(), child: AnimatedBuilder( animation: _ctrl, builder: (context, _) { @@ -837,6 +856,7 @@ class _SidebarState extends State<_Sidebar> children: [ for (var i = 0; i < widget.pages.length; i++) _SidebarItem( + key: ValueKey('sidebar-item-${widget.pages[i].id}'), page: widget.pages[i], selected: i == widget.selectedIndex, t: t, @@ -1342,6 +1362,7 @@ class _SidebarItem extends StatefulWidget { final int? badge; const _SidebarItem({ + super.key, required this.page, required this.selected, required this.t, diff --git a/test/sidebar_test.dart b/test/sidebar_test.dart new file mode 100644 index 0000000..15d87e6 --- /dev/null +++ b/test/sidebar_test.dart @@ -0,0 +1,77 @@ +// 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 = [ + 'welcome', + 'store', + 'doctor', + 'flows', + 'audit', + 'approvals', + 'federation', +]; + +Map _destinationYs(WidgetTester tester) { + final ys = {}; + 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', + ); + } + }, + ); +} From c9fa068991dc8a8a9dce63e235081793e7a38d29 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 21 Jun 2026 21:04:13 +0200 Subject: [PATCH 3/8] feat(studio): guided-setup wizard (reuses chain init engine) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Welcome 'Setup assistant' button opens a wizard that collects scenario / intent / target (+ approval & data-local toggles), then calls `chain init --answers` to preview the assembled plan and `--apply --force` to write the config — reusing the Rust deterministic engine, no logic duplicated. New SystemActions.chainInit; copyable errors via showFaiProcessError; EN+DE l10n. analyze clean; smoke test + existing welcome/sidebar tests pass. Signed-off-by: flemming-it --- lib/data/system_actions.dart | 9 + lib/l10n/app_de.arb | 14 ++ lib/l10n/app_en.arb | 14 ++ lib/l10n/app_localizations.dart | 84 +++++++++ lib/l10n/app_localizations_de.dart | 45 +++++ lib/l10n/app_localizations_en.dart | 46 +++++ lib/pages/welcome.dart | 10 ++ lib/widgets/guided_setup_dialog.dart | 257 +++++++++++++++++++++++++++ test/guided_setup_test.dart | 30 ++++ 9 files changed, 509 insertions(+) create mode 100644 lib/widgets/guided_setup_dialog.dart create mode 100644 test/guided_setup_test.dart diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index 24598a5..b17fdb8 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -143,6 +143,15 @@ class SystemActions { return _runFai(['daemon', ...args]); } + /// Run a `chain init ...` subcommand. Used by the guided-setup + /// wizard to preview a plan (`--answers `) and apply it + /// (`--answers --apply --force`). + static Future<({bool ok, String stdout, String stderr})> chainInit( + List args, + ) async { + return _runFai(['init', ...args]); + } + /// Run `chain update apply --channel `. Long-running on a slow /// network — caller should show a spinner. static Future<({bool ok, String stdout, String stderr})> chainUpdateApply( diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d74a093..1c54747 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,5 +1,19 @@ { "@@locale": "de", + "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", "navWelcome": "Willkommen", "welcomeHeroTitle": "Ch∆In Platform", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2f5c0e1..55a1b58 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,5 +1,19 @@ { "@@locale": "en", + "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": { "description": "App-bar title in the OS task switcher." diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5948825..31a683d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -98,6 +98,90 @@ abstract class AppLocalizations { Locale('en'), ]; + /// 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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6ba161e..4c037c9 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -8,6 +8,51 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @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 String get appTitle => 'Ch∆In Studio'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c1114a9..55b0a43 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -8,6 +8,52 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @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 String get appTitle => 'Ch∆In Studio'; diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 6afa872..bffebee 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -19,6 +19,7 @@ import '../l10n/app_localizations.dart'; import '../main.dart' show StudioShellState; import '../theme/theme.dart'; import '../theme/tokens.dart'; +import '../widgets/guided_setup_dialog.dart'; import '../widgets/widgets.dart'; class WelcomePage extends StatelessWidget { @@ -59,6 +60,15 @@ class WelcomePage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const _Hero(), + const SizedBox(height: ChainSpace.md), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: () => GuidedSetupDialog.show(context), + icon: const Icon(Icons.auto_fix_high), + label: Text(AppLocalizations.of(context)!.guidedSetupTitle), + ), + ), const SizedBox(height: ChainSpace.xxl), // When the hub is down the onboarding checklist // would render dead (all-unchecked, nothing to diff --git a/lib/widgets/guided_setup_dialog.dart b/lib/widgets/guided_setup_dialog.dart new file mode 100644 index 0000000..94f7b8c --- /dev/null +++ b/lib/widgets/guided_setup_dialog.dart @@ -0,0 +1,257 @@ +// 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 show(BuildContext context) => showDialog( + context: context, + builder: (_) => const GuidedSetupDialog(), + ); + + @override + State createState() => _GuidedSetupDialogState(); +} + +class _GuidedSetupDialogState extends State { + // Canonical SetupAnswers enum values (serde kebab-case in Rust). + static const _scenarios = [ + 'trying-out', + 'team-hub', + 'regulated-production', + 'building-modules', + ]; + static const _intents = [ + 'hello-world', + 'extract-text', + 'extract-summarize', + 'classify-documents', + 'build-module', + ]; + static const _targets = [ + '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 _writeAnswers() async { + final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml'); + await f.writeAsString(_answersYaml()); + return f.path; + } + + Future _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 _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 items, + String value, + ValueChanged onChanged, + ) { + return InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + isDense: true, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: value, + items: [ + for (final i in items) + DropdownMenuItem(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 _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)), + ]; + } +} diff --git a/test/guided_setup_test.dart b/test/guided_setup_test.dart new file mode 100644 index 0000000..b6fa967 --- /dev/null +++ b/test/guided_setup_test.dart @@ -0,0 +1,30 @@ +// 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), findsNWidgets(3)); + // The "Next" action is present (advances to the review/apply step). + expect(find.widgetWithText(FilledButton, 'Next'), findsOneWidget); + }); +} From 657e68efee2f8d1fd2bd36d15c4ab286d5f8ad31 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 22 Jun 2026 00:50:21 +0200 Subject: [PATCH 4/8] fix(studio): show real download error, not 'hub not reachable' friendlyError now pattern-matches module-download failures ('download failed: ...') and renders a clear, copyable headline + the URL/status detail, instead of letting the gRPC-Unavailable default show the generic 'hub not reachable' banner. EN+DE l10n. Signed-off-by: flemming-it --- lib/data/friendly_error.dart | 13 +++++++++++++ lib/l10n/app_de.arb | 2 ++ lib/l10n/app_en.arb | 2 ++ lib/l10n/app_localizations.dart | 12 ++++++++++++ lib/l10n/app_localizations_de.dart | 7 +++++++ lib/l10n/app_localizations_en.dart | 7 +++++++ 6 files changed, 43 insertions(+) diff --git a/lib/data/friendly_error.dart b/lib/data/friendly_error.dart index ba2416f..a7026b3 100644 --- a/lib/data/friendly_error.dart +++ b/lib/data/friendly_error.dart @@ -122,6 +122,19 @@ FriendlyError friendlyError(Object error, AppLocalizations l) { FriendlyError? _matchHubPattern(String detail, AppLocalizations l) { 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" if (detail.contains('rejected by')) { return FriendlyError( diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1c54747..bad06e9 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,5 +1,7 @@ { "@@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.", "guidedSetupTitle": "Setup-Assistent", "guidedSetupIntro": "Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.", "guidedSetupScenario": "Szenario", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 55a1b58..8ac4f2b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,5 +1,7 @@ { "@@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.", "guidedSetupTitle": "Setup assistant", "guidedSetupIntro": "Answer three questions and Ch∆In assembles a tailored config, module set and starter flow.", "guidedSetupScenario": "Scenario", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 31a683d..61235dd 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -98,6 +98,18 @@ abstract class AppLocalizations { 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 @guidedSetupTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 4c037c9..acf8b10 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -8,6 +8,13 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { 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 get guidedSetupTitle => 'Setup-Assistent'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 55b0a43..615cd53 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -8,6 +8,13 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { 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 get guidedSetupTitle => 'Setup assistant'; From 255348c062567e141e9919a7038c46663addc70a Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 22 Jun 2026 00:53:22 +0200 Subject: [PATCH 5/8] fix(studio): store grid uses _ModuleIcon (aligned icons + icon URLs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid cell rendered a bare category glyph in a CircleAvatar; the detail view uses _ModuleIcon (module icon URL with category fallback). Grid now uses _ModuleIcon too — consistent, aligned, shows per-module icons. Signed-off-by: flemming-it --- lib/pages/store.dart | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 06e30ac..c812aba 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -1899,16 +1899,10 @@ class _StoreCardState extends State<_StoreCard> { children: [ Row( children: [ - CircleAvatar( + _ModuleIcon( + iconUrl: item.iconUrl, + category: item.category, 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), Expanded( From 27cf7e83829b563bda9cddf5723fca39e8ed5551 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 22 Jun 2026 00:59:51 +0200 Subject: [PATCH 6/8] feat(studio): show module origin store ('from ') StoreItem gains source (mapped from StoreEntry.source); the store grid shows a 'from ' label for operator-added stores (bundled seed unlabelled). Foundation for grouping by store. EN+DE l10n. Signed-off-by: flemming-it --- lib/data/hub.dart | 8 ++++++++ lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 6 ++++++ lib/l10n/app_localizations.dart | 6 ++++++ lib/l10n/app_localizations_de.dart | 5 +++++ lib/l10n/app_localizations_en.dart | 5 +++++ lib/pages/store.dart | 13 +++++++++++++ 7 files changed, 44 insertions(+) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 27eb86c..df4b7fc 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -786,6 +786,7 @@ class HubService { kind: e.kind, provider: e.provider, sourceKind: e.sourceKind, + source: e.source, ), ) .toList(); @@ -1793,6 +1794,12 @@ class StoreItem { /// from `kind` for pre-0.12 hubs. 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'; const StoreItem({ @@ -1817,5 +1824,6 @@ class StoreItem { required this.kind, required this.provider, this.sourceKind = '', + this.source = '', }); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index bad06e9..655204b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2,6 +2,7 @@ "@@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", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8ac4f2b..3b0aa3e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2,6 +2,12 @@ "@@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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 61235dd..bc599f3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -110,6 +110,12 @@ abstract class AppLocalizations { /// **'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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index acf8b10..ec388a1 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -15,6 +15,11 @@ class AppLocalizationsDe extends AppLocalizations { 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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 615cd53..f51f4ed 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -15,6 +15,11 @@ class AppLocalizationsEn extends AppLocalizations { 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'; diff --git a/lib/pages/store.dart b/lib/pages/store.dart index c812aba..d21a7bf 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -1930,6 +1930,19 @@ class _StoreCardState extends State<_StoreCard> { color: theme.colorScheme.onSurfaceVariant, ), ), + // Origin store ("from ") — 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, + ), + ), ], ), ), From 46918769ce064908b810dcf9653f814801dc35e2 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 22 Jun 2026 01:04:17 +0200 Subject: [PATCH 7/8] fix(studio): keep sidebar expanded while channel menu is open Opening the channel switcher let the hover-exit collapse the rail at the same moment, leaving the menu floating at the pill's old (expanded) position. The pill now signals open/close; the sidebar suppresses its collapse (_menuOpen) while the menu is up, so it stays aligned. Signed-off-by: flemming-it --- lib/main.dart | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 75aaa20..71a3cf0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -701,6 +701,10 @@ class _SidebarState extends State<_Sidebar> static const double _channelRowH = 28; static const double _rowGap = 8; 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 void initState() { @@ -760,7 +764,9 @@ class _SidebarState extends State<_Sidebar> widget.activeChannel != null && widget.activeChannel!.isNotEmpty; return MouseRegion( onEnter: widget.forceExpanded ? null : (_) => _ctrl.forward(), - onExit: widget.forceExpanded ? null : (_) => _ctrl.reverse(), + onExit: (widget.forceExpanded || _menuOpen) + ? null + : (_) => _ctrl.reverse(), child: AnimatedBuilder( animation: _ctrl, builder: (context, _) { @@ -843,7 +849,18 @@ class _SidebarState extends State<_Sidebar> channel: widget.activeChannel!, ), ), - label: _ChannelPill(channel: widget.activeChannel!), + label: _ChannelPill( + channel: widget.activeChannel!, + onMenuOpen: () { + setState(() => _menuOpen = true); + _ctrl.forward(); + }, + onMenuClose: () { + if (!mounted) return; + setState(() => _menuOpen = false); + _ctrl.reverse(); + }, + ), t: t, labelsInteractive: labelsInteractive, ) @@ -1140,7 +1157,17 @@ class _ChannelMenuItem extends StatelessWidget { /// unmistakeable. class _ChannelPill extends StatelessWidget { 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 Widget build(BuildContext context) { @@ -1154,7 +1181,11 @@ class _ChannelPill extends StatelessWidget { borderRadius: BorderRadius.circular(ChainRadius.sm), child: InkWell( borderRadius: BorderRadius.circular(ChainRadius.sm), - onTap: () => _showChannelSwitchMenu(context, channel), + onTap: () async { + onMenuOpen?.call(); + await _showChannelSwitchMenu(context, channel); + onMenuClose?.call(); + }, child: Container( padding: const EdgeInsets.symmetric( horizontal: ChainSpace.sm, From 5d511dcd99ec3c4f187a38bfc8640c2a3b79a6fe Mon Sep 17 00:00:00 2001 From: flemming-it Date: Wed, 1 Jul 2026 02:34:53 +0200 Subject: [PATCH 8/8] fix(studio): promote guided-setup button + remove dead ModulesPage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guided setup is the fastest path to a working first run, so lead with a FilledButton instead of a low-emphasis outlined one (welcome page). Delete pages/modules.dart — ModulesPage was never routed (superseded by the Store 'Installed' filter) and nothing imports it; flutter analyze stays clean. Signed-off-by: flemming-it --- lib/pages/modules.dart | 305 ----------------------------------------- lib/pages/welcome.dart | 5 +- 2 files changed, 4 insertions(+), 306 deletions(-) delete mode 100644 lib/pages/modules.dart diff --git a/lib/pages/modules.dart b/lib/pages/modules.dart deleted file mode 100644 index 24ccf40..0000000 --- a/lib/pages/modules.dart +++ /dev/null @@ -1,305 +0,0 @@ -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 createState() => _ModulesPageState(); -} - -class _ModulesPageState extends State { - late Future> _future; - late Future> _historyFuture; - - @override - void initState() { - super.initState(); - _future = HubService.instance.listModules(); - _historyFuture = _loadHistory(); - } - - Future> _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>( - 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> future; - const _RecentActivityPanel({required this.future}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return FutureBuilder>( - future: future, - builder: (context, snap) { - final events = snap.data ?? const []; - 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'; -} diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index bffebee..144b52f 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -63,7 +63,10 @@ class WelcomePage extends StatelessWidget { const SizedBox(height: ChainSpace.md), Align( alignment: Alignment.centerLeft, - child: OutlinedButton.icon( + // 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),