diff --git a/lib/data/friendly_error.dart b/lib/data/friendly_error.dart index a7026b3..ba2416f 100644 --- a/lib/data/friendly_error.dart +++ b/lib/data/friendly_error.dart @@ -122,19 +122,6 @@ 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/data/hub.dart b/lib/data/hub.dart index df4b7fc..27eb86c 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -786,7 +786,6 @@ class HubService { kind: e.kind, provider: e.provider, sourceKind: e.sourceKind, - source: e.source, ), ) .toList(); @@ -1794,12 +1793,6 @@ 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({ @@ -1824,6 +1817,5 @@ class StoreItem { required this.kind, required this.provider, this.sourceKind = '', - this.source = '', }); } diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index b17fdb8..6cd8149 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -143,15 +143,6 @@ 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( @@ -251,10 +242,7 @@ class SystemActions { } final isWindows = Platform.isWindows; - // 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'); + final fromPath = _whichFai(isWindows ? 'fai.exe' : 'fai'); if (fromPath != null) return fromPath; final home = Platform.environment[isWindows ? 'USERPROFILE' : 'HOME']; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 655204b..d74a093 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,22 +1,5 @@ { "@@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", "navWelcome": "Willkommen", "welcomeHeroTitle": "Ch∆In Platform", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3b0aa3e..2f5c0e1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,27 +1,5 @@ { "@@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": { "description": "App-bar title in the OS task switcher." diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bc599f3..5948825 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -98,108 +98,6 @@ 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 @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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ec388a1..6ba161e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -8,63 +8,6 @@ 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 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 String get appTitle => 'Ch∆In Studio'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f51f4ed..c1114a9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -8,64 +8,6 @@ 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 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 String get appTitle => 'Ch∆In Studio'; diff --git a/lib/main.dart b/lib/main.dart index 71a3cf0..e7da38c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -60,18 +60,11 @@ 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 @@ -233,9 +226,7 @@ class StudioAppState extends State { supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, - home: StudioShell( - startSidebarExpanded: widget.startSidebarExpanded, - ), + home: const StudioShell(), ); }, ), @@ -246,10 +237,7 @@ class StudioAppState extends State { } class StudioShell extends StatefulWidget { - /// Test-only: forward to the sidebar so it starts expanded. - final bool startSidebarExpanded; - - const StudioShell({super.key, this.startSidebarExpanded = false}); + const StudioShell({super.key}); @override State createState() => StudioShellState(); @@ -535,7 +523,6 @@ class StudioShellState extends State { endpointLabel: HubService.instance.endpointLabel, activeChannel: _activeChannel, pendingApprovals: _pendingApprovals, - forceExpanded: widget.startSidebarExpanded, ), Container(width: 1, color: theme.colorScheme.outlineVariant), Expanded( @@ -662,11 +649,6 @@ 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, @@ -675,7 +657,6 @@ class _Sidebar extends StatefulWidget { required this.endpointLabel, required this.activeChannel, this.pendingApprovals = 0, - this.forceExpanded = false, }); @override @@ -701,10 +682,6 @@ 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() { @@ -717,7 +694,7 @@ class _SidebarState extends State<_Sidebar> // the rail feels crisp rather than sluggish. duration: ChainMotion.slow, reverseDuration: ChainMotion.base, - value: widget.forceExpanded ? 1 : 0, + value: 0, ); } @@ -763,10 +740,8 @@ class _SidebarState extends State<_Sidebar> final hasChannel = widget.activeChannel != null && widget.activeChannel!.isNotEmpty; return MouseRegion( - onEnter: widget.forceExpanded ? null : (_) => _ctrl.forward(), - onExit: (widget.forceExpanded || _menuOpen) - ? null - : (_) => _ctrl.reverse(), + onEnter: (_) => _ctrl.forward(), + onExit: (_) => _ctrl.reverse(), child: AnimatedBuilder( animation: _ctrl, builder: (context, _) { @@ -849,18 +824,7 @@ class _SidebarState extends State<_Sidebar> channel: widget.activeChannel!, ), ), - label: _ChannelPill( - channel: widget.activeChannel!, - onMenuOpen: () { - setState(() => _menuOpen = true); - _ctrl.forward(); - }, - onMenuClose: () { - if (!mounted) return; - setState(() => _menuOpen = false); - _ctrl.reverse(); - }, - ), + label: _ChannelPill(channel: widget.activeChannel!), t: t, labelsInteractive: labelsInteractive, ) @@ -873,7 +837,6 @@ 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, @@ -1157,17 +1120,7 @@ class _ChannelMenuItem extends StatelessWidget { /// unmistakeable. class _ChannelPill extends StatelessWidget { final String 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, - }); + const _ChannelPill({required this.channel}); @override Widget build(BuildContext context) { @@ -1181,11 +1134,7 @@ class _ChannelPill extends StatelessWidget { borderRadius: BorderRadius.circular(ChainRadius.sm), child: InkWell( borderRadius: BorderRadius.circular(ChainRadius.sm), - onTap: () async { - onMenuOpen?.call(); - await _showChannelSwitchMenu(context, channel); - onMenuClose?.call(); - }, + onTap: () => _showChannelSwitchMenu(context, channel), child: Container( padding: const EdgeInsets.symmetric( horizontal: ChainSpace.sm, @@ -1393,7 +1342,6 @@ class _SidebarItem extends StatefulWidget { final int? badge; const _SidebarItem({ - super.key, required this.page, required this.selected, required this.t, diff --git a/lib/pages/modules.dart b/lib/pages/modules.dart new file mode 100644 index 0000000..24ccf40 --- /dev/null +++ b/lib/pages/modules.dart @@ -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 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/store.dart b/lib/pages/store.dart index d21a7bf..06e30ac 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -1899,10 +1899,16 @@ class _StoreCardState extends State<_StoreCard> { children: [ Row( children: [ - _ModuleIcon( - iconUrl: item.iconUrl, - category: item.category, + CircleAvatar( 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( @@ -1930,19 +1936,6 @@ 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, - ), - ), ], ), ), diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 144b52f..6afa872 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -19,7 +19,6 @@ 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 { @@ -60,18 +59,6 @@ class WelcomePage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ 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), // 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 deleted file mode 100644 index 94f7b8c..0000000 --- a/lib/widgets/guided_setup_dialog.dart +++ /dev/null @@ -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 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 deleted file mode 100644 index b6fa967..0000000 --- a/test/guided_setup_test.dart +++ /dev/null @@ -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), findsNWidgets(3)); - // The "Next" action is present (advances to the review/apply step). - expect(find.widgetWithText(FilledButton, 'Next'), findsOneWidget); - }); -} diff --git a/test/sidebar_test.dart b/test/sidebar_test.dart deleted file mode 100644 index 15d87e6..0000000 --- a/test/sidebar_test.dart +++ /dev/null @@ -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 = [ - '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', - ); - } - }, - ); -}