From 751186777485f6578d9c5b4dd5cadbdbc013c0bd Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 9 Jun 2026 09:41:14 +0200 Subject: [PATCH] feat(studio): pending-approvals sidebar badge + per-category settings help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-attention improvements: - The sidebar's Approvals item gains a small accent-coloured badge (e.g. '2', '9+') when there are pending approvals, polled on the same 5 s tick the health check uses. Operators no longer need to open the Approvals page to discover new pending decisions; the badge surfaces them at the rail level. - The Settings dialog's per-category panel titles grow an optional help icon (?) that opens the matching bundled docs explainer via showFaiDoc. Wired for General → architecture, Security → security, Maintenance → audit. Other categories deliberately stay un-iced for now — Appearance / System AI / Integrations don't yet have a standalone bundled doc, and showing a broken help icon would be worse than showing none. Studio bumped to 0.69.0. Signed-off-by: flemming-it --- lib/main.dart | 74 +++++++++++++++++++++++++--- lib/widgets/fai_settings_dialog.dart | 34 ++++++++++--- pubspec.yaml | 2 +- 3 files changed, 95 insertions(+), 15 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 184b4f9..301e5be 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,7 +27,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.68.0'; +const String kStudioVersion = '0.69.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -313,6 +313,11 @@ class StudioShellState extends State { ), ]; + /// Pending-approval count surfaced as a sidebar badge so the + /// operator notices new approvals without polling the page. + /// Refreshed alongside the health probe — same 5 s tick. + int _pendingApprovals = 0; + @override void initState() { super.initState(); @@ -327,9 +332,6 @@ class StudioShellState extends State { final ok = await HubService.instance.healthy(); if (!mounted) return; if (_connected != ok) setState(() => _connected = ok); - // Refresh the active channel pointer alongside the health - // probe — cheap, and keeps the sidebar pill honest if the - // operator switched channels from the CLI. if (ok) { try { final snap = await HubService.instance.channelStatus(); @@ -337,9 +339,14 @@ class StudioShellState extends State { if (_activeChannel != snap.active) { setState(() => _activeChannel = snap.active); } - } catch (_) { - // Best-effort; pill simply doesn't update this tick. - } + } catch (_) {/* best-effort */} + try { + final pending = await HubService.instance.pendingApprovals(); + if (!mounted) return; + if (_pendingApprovals != pending.length) { + setState(() => _pendingApprovals = pending.length); + } + } catch (_) {/* best-effort */} } } @@ -443,6 +450,7 @@ class StudioShellState extends State { connected: _connected, endpointLabel: HubService.instance.endpointLabel, activeChannel: _activeChannel, + pendingApprovals: _pendingApprovals, ), Container(width: 1, color: theme.colorScheme.outlineVariant), Expanded( @@ -501,6 +509,11 @@ class _Sidebar extends StatefulWidget { /// initial fetch is in flight or the hub is unreachable. final String? activeChannel; + /// Number of pending approvals — surfaced as a badge on the + /// matching nav item so the operator notices a new approval + /// without polling the page. + final int pendingApprovals; + const _Sidebar({ required this.selectedIndex, required this.onSelect, @@ -508,6 +521,7 @@ class _Sidebar extends StatefulWidget { required this.connected, required this.endpointLabel, required this.activeChannel, + this.pendingApprovals = 0, }); @override @@ -672,6 +686,10 @@ class _SidebarState extends State<_Sidebar> t: t, labelsInteractive: labelsInteractive, iconColumnWidth: _collapsedWidth, + badge: widget.pages[i].id == 'approvals' && + widget.pendingApprovals > 0 + ? widget.pendingApprovals + : null, onTap: () => widget.onSelect(i), ), ], @@ -1016,6 +1034,11 @@ class _SidebarItem extends StatefulWidget { final double iconColumnWidth; final VoidCallback onTap; + /// Optional count badge — null hides it, non-null renders a + /// small accent pill on top-right of the icon. Used by the + /// Approvals item for pending count. + final int? badge; + const _SidebarItem({ required this.page, required this.selected, @@ -1023,6 +1046,7 @@ class _SidebarItem extends StatefulWidget { required this.labelsInteractive, required this.iconColumnWidth, required this.onTap, + this.badge, }); @override @@ -1047,11 +1071,45 @@ class _SidebarItemState extends State<_SidebarItem> { : Colors.transparent; final label = widget.page.labelOf(context); - final icon = Icon( + Widget icon = Icon( widget.selected ? widget.page.selectedIcon : widget.page.icon, size: 18, color: color, ); + final badge = widget.badge; + if (badge != null) { + icon = Stack( + clipBehavior: Clip.none, + children: [ + icon, + Positioned( + right: -6, + top: -4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 1, + ), + constraints: const BoxConstraints(minWidth: 16, minHeight: 14), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + badge > 9 ? '9+' : '$badge', + textAlign: TextAlign.center, + style: TextStyle( + color: theme.colorScheme.onPrimary, + fontSize: 9, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + ), + ), + ], + ); + } // Label widget is ALWAYS in the tree — opacity = t makes // it fade in/out in lockstep with the rail's width // animation. Expanded's flex space goes from 0 (at t=0, diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index f7d70b1..9ad7460 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -11,6 +11,7 @@ import '../data/hub_auth_token.dart'; import '../data/registry_token.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; +import '../pages/welcome.dart' show showFaiDoc; import '../theme/theme.dart'; import '../theme/tokens.dart'; import 'fai_error_box.dart'; @@ -394,17 +395,35 @@ class _FaiSettingsDialogState extends State { ); } - Widget _panelTitle(String title, String subtitle, ThemeData theme) { + Widget _panelTitle( + String title, + String subtitle, + ThemeData theme, { + String? docSlug, + }) { return Padding( padding: const EdgeInsets.only(bottom: FaiSpace.lg), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), + Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + if (docSlug != null) + IconButton( + icon: const Icon(Icons.help_outline, size: 18), + tooltip: AppLocalizations.of(context)!.helpTooltip, + onPressed: () => showFaiDoc(context, docSlug), + visualDensity: VisualDensity.compact, + ), + ], ), const SizedBox(height: 4), Text( @@ -424,6 +443,7 @@ class _FaiSettingsDialogState extends State { l.settingsTitle, l.settingsHubEndpointHint, theme, + docSlug: 'architecture', ), TextField( controller: _host, @@ -618,6 +638,7 @@ class _FaiSettingsDialogState extends State { l.settingsSecurityPanelTitle, l.settingsSecurityPanelBody, theme, + docSlug: 'security', ), _RegistryCredentialsPanel( configuredChars: _registryTokenChars, @@ -639,6 +660,7 @@ class _FaiSettingsDialogState extends State { l.settingsMaintenancePanelTitle, l.settingsMaintenancePanelBody, theme, + docSlug: 'audit', ), _MaintenancePanel( onResetDone: () async { diff --git a/pubspec.yaml b/pubspec.yaml index a59304b..931dc4e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.68.0 +version: 0.69.0 environment: sdk: ^3.11.0-200.1.beta