feat(studio): pending-approvals sidebar badge + per-category settings help
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
5aae3285df
commit
7511867774
3 changed files with 95 additions and 15 deletions
|
|
@ -27,7 +27,7 @@ import 'widgets/widgets.dart';
|
||||||
/// Studio's own build version. Bump on every UI commit so the
|
/// Studio's own build version. Bump on every UI commit so the
|
||||||
/// running app self-identifies — visible in the sidebar header
|
/// running app self-identifies — visible in the sidebar header
|
||||||
/// and quick-glance proof that you're seeing the current build.
|
/// and quick-glance proof that you're seeing the current build.
|
||||||
const String kStudioVersion = '0.68.0';
|
const String kStudioVersion = '0.69.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -313,6 +313,11 @@ class StudioShellState extends State<StudioShell> {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// 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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -327,9 +332,6 @@ class StudioShellState extends State<StudioShell> {
|
||||||
final ok = await HubService.instance.healthy();
|
final ok = await HubService.instance.healthy();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (_connected != ok) setState(() => _connected = ok);
|
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) {
|
if (ok) {
|
||||||
try {
|
try {
|
||||||
final snap = await HubService.instance.channelStatus();
|
final snap = await HubService.instance.channelStatus();
|
||||||
|
|
@ -337,9 +339,14 @@ class StudioShellState extends State<StudioShell> {
|
||||||
if (_activeChannel != snap.active) {
|
if (_activeChannel != snap.active) {
|
||||||
setState(() => _activeChannel = snap.active);
|
setState(() => _activeChannel = snap.active);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {/* best-effort */}
|
||||||
// Best-effort; pill simply doesn't update this tick.
|
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<StudioShell> {
|
||||||
connected: _connected,
|
connected: _connected,
|
||||||
endpointLabel: HubService.instance.endpointLabel,
|
endpointLabel: HubService.instance.endpointLabel,
|
||||||
activeChannel: _activeChannel,
|
activeChannel: _activeChannel,
|
||||||
|
pendingApprovals: _pendingApprovals,
|
||||||
),
|
),
|
||||||
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -501,6 +509,11 @@ class _Sidebar extends StatefulWidget {
|
||||||
/// initial fetch is in flight or the hub is unreachable.
|
/// initial fetch is in flight or the hub is unreachable.
|
||||||
final String? activeChannel;
|
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({
|
const _Sidebar({
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onSelect,
|
required this.onSelect,
|
||||||
|
|
@ -508,6 +521,7 @@ class _Sidebar extends StatefulWidget {
|
||||||
required this.connected,
|
required this.connected,
|
||||||
required this.endpointLabel,
|
required this.endpointLabel,
|
||||||
required this.activeChannel,
|
required this.activeChannel,
|
||||||
|
this.pendingApprovals = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -672,6 +686,10 @@ class _SidebarState extends State<_Sidebar>
|
||||||
t: t,
|
t: t,
|
||||||
labelsInteractive: labelsInteractive,
|
labelsInteractive: labelsInteractive,
|
||||||
iconColumnWidth: _collapsedWidth,
|
iconColumnWidth: _collapsedWidth,
|
||||||
|
badge: widget.pages[i].id == 'approvals' &&
|
||||||
|
widget.pendingApprovals > 0
|
||||||
|
? widget.pendingApprovals
|
||||||
|
: null,
|
||||||
onTap: () => widget.onSelect(i),
|
onTap: () => widget.onSelect(i),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -1016,6 +1034,11 @@ class _SidebarItem extends StatefulWidget {
|
||||||
final double iconColumnWidth;
|
final double iconColumnWidth;
|
||||||
final VoidCallback onTap;
|
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({
|
const _SidebarItem({
|
||||||
required this.page,
|
required this.page,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
|
|
@ -1023,6 +1046,7 @@ class _SidebarItem extends StatefulWidget {
|
||||||
required this.labelsInteractive,
|
required this.labelsInteractive,
|
||||||
required this.iconColumnWidth,
|
required this.iconColumnWidth,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.badge,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -1047,11 +1071,45 @@ class _SidebarItemState extends State<_SidebarItem> {
|
||||||
: Colors.transparent;
|
: Colors.transparent;
|
||||||
final label = widget.page.labelOf(context);
|
final label = widget.page.labelOf(context);
|
||||||
|
|
||||||
final icon = Icon(
|
Widget icon = Icon(
|
||||||
widget.selected ? widget.page.selectedIcon : widget.page.icon,
|
widget.selected ? widget.page.selectedIcon : widget.page.icon,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: color,
|
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
|
// Label widget is ALWAYS in the tree — opacity = t makes
|
||||||
// it fade in/out in lockstep with the rail's width
|
// it fade in/out in lockstep with the rail's width
|
||||||
// animation. Expanded's flex space goes from 0 (at t=0,
|
// animation. Expanded's flex space goes from 0 (at t=0,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import '../data/hub_auth_token.dart';
|
||||||
import '../data/registry_token.dart';
|
import '../data/registry_token.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../pages/welcome.dart' show showFaiDoc;
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
import 'fai_error_box.dart';
|
import 'fai_error_box.dart';
|
||||||
|
|
@ -394,18 +395,36 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _panelTitle(String title, String subtitle, ThemeData theme) {
|
Widget _panelTitle(
|
||||||
|
String title,
|
||||||
|
String subtitle,
|
||||||
|
ThemeData theme, {
|
||||||
|
String? docSlug,
|
||||||
|
}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: FaiSpace.lg),
|
padding: const EdgeInsets.only(bottom: FaiSpace.lg),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
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),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
|
|
@ -424,6 +443,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
l.settingsTitle,
|
l.settingsTitle,
|
||||||
l.settingsHubEndpointHint,
|
l.settingsHubEndpointHint,
|
||||||
theme,
|
theme,
|
||||||
|
docSlug: 'architecture',
|
||||||
),
|
),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _host,
|
controller: _host,
|
||||||
|
|
@ -618,6 +638,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
l.settingsSecurityPanelTitle,
|
l.settingsSecurityPanelTitle,
|
||||||
l.settingsSecurityPanelBody,
|
l.settingsSecurityPanelBody,
|
||||||
theme,
|
theme,
|
||||||
|
docSlug: 'security',
|
||||||
),
|
),
|
||||||
_RegistryCredentialsPanel(
|
_RegistryCredentialsPanel(
|
||||||
configuredChars: _registryTokenChars,
|
configuredChars: _registryTokenChars,
|
||||||
|
|
@ -639,6 +660,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
l.settingsMaintenancePanelTitle,
|
l.settingsMaintenancePanelTitle,
|
||||||
l.settingsMaintenancePanelBody,
|
l.settingsMaintenancePanelBody,
|
||||||
theme,
|
theme,
|
||||||
|
docSlug: 'audit',
|
||||||
),
|
),
|
||||||
_MaintenancePanel(
|
_MaintenancePanel(
|
||||||
onResetDone: () async {
|
onResetDone: () async {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: fai_studio
|
name: fai_studio
|
||||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.68.0
|
version: 0.69.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue