feat(editor): FaiEditorStyle + frosted glass panel + pattern/zoom dropdowns

Visual-effect knobs (FaiEditorStyle) the host can override —
distinct from ColorScheme, which stays orthogonal. Today
Studio passes it through FlowEditorPage's new style:
parameter; tomorrow a theme plugin can ship both colours and
effects in one move.

Two presets ship today: modern (frosted glass, gradient
backdrop, animated flow, shadowed nodes — the default) and
minimal (everything off, flat surfaces — accessibility /
reduce-motion / low-spec).

Properties panel rebuilt as a floating sidebar with
BackdropFilter blur when glass-style is on; falls back to
the pre-0.7 flush-divider layout under solid style.

Pattern + zoom controls promoted to PopupMenuButton dropdowns
so operators reach a specific zoom level / pattern in one
click instead of cycling.

Bumps fai_studio_flow_editor to 0.8.0.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 18:10:51 +02:00
parent 7aeeae717f
commit 1f5601a461
6 changed files with 386 additions and 130 deletions

View file

@ -23,12 +23,14 @@
library;
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'editor_controller.dart';
import 'editor_style.dart';
import 'l10n.dart';
import 'model/flow_graph.dart';
import 'run_driver.dart';
@ -65,12 +67,21 @@ class FlowEditorPage extends StatefulWidget {
/// Empty list = the picker shows a free-form text field.
final List<String> availableCapabilities;
/// Visual-effect overrides frosted glass on / off, canvas
/// backdrop style, flow animation on / off. When null the
/// package's [FaiEditorStyle.modern] preset is used. A
/// theme-plugin host can pass its own to flip the editor
/// between Studio's active style modes without touching
/// the editor's source.
final FaiEditorStyle? style;
const FlowEditorPage({
super.key,
this.initialFlowName,
this.locale = FlowEditorLocale.en,
this.runDriver,
this.availableCapabilities = const [],
this.style,
});
@override
@ -432,71 +443,119 @@ outputs:
}
Widget _graphTab(ThemeData theme) {
// When a step is selected, show the properties panel on
// the right as a fixed sidebar. Selection is owned by
// the controller and ChangeNotifier rebuilds drive the
// panel show / hide. When the flow has no steps at all,
// overlay a call-to-action so the operator's first
// instinct is the right action rather than staring at an
// empty grid.
// When a step is selected, show the properties panel as a
// floating sidebar on the right. In glass-style mode the
// panel overlaps the canvas so the BackdropFilter has
// canvas content to blur. In solid-style mode the panel
// sits flush against the canvas with a divider no
// overlap needed.
//
// When the flow has no steps at all, overlay a CTA so the
// operator's first instinct is the right action rather
// than staring at an empty grid.
final hasSteps = _controller.graph.steps.isNotEmpty;
final style = widget.style ?? FaiEditorStyle.modern;
final glass = style.panelStyle == EditorPanelStyle.glass;
final hasSelection = _controller.selectedStepId != null;
Widget emptyOverlay() => Positioned.fill(
child: Container(
color: theme.colorScheme.surface.withValues(alpha: 0.92),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.account_tree_outlined,
size: 48,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: FaiSpace.md),
Text(_l.graphEmptyTitle, style: theme.textTheme.titleMedium),
const SizedBox(height: FaiSpace.sm),
Text(
_l.graphEmptyBody,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
FilledButton.icon(
onPressed: _addStep,
icon: const Icon(Icons.add_box_outlined, size: 16),
label: Text(_l.addStep),
),
],
),
),
),
);
Widget panel() => PropertiesPanel(
controller: _controller,
strings: _l,
availableCapabilities: widget.availableCapabilities,
);
if (glass) {
// Stack layout so the frosted panel can overlap canvas.
return Stack(
children: [
Positioned.fill(
child: FlowCanvas(controller: _controller, style: style),
),
if (!hasSteps) emptyOverlay(),
if (hasSelection)
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 320,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: style.panelBlurSigma,
sigmaY: style.panelBlurSigma,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(
alpha: style.panelBackgroundAlpha,
),
border: Border(
left: BorderSide(
color: theme.colorScheme.outlineVariant.withValues(
alpha: 0.5,
),
width: 1,
),
),
),
child: panel(),
),
),
),
),
],
);
}
// Solid-style: flush sidebar, divider, no blur.
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
children: [
FlowCanvas(controller: _controller),
if (!hasSteps)
Positioned.fill(
child: Container(
color: theme.colorScheme.surface.withValues(alpha: 0.92),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.account_tree_outlined,
size: 48,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: FaiSpace.md),
Text(
_l.graphEmptyTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: FaiSpace.sm),
Text(
_l.graphEmptyBody,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
FilledButton.icon(
onPressed: _addStep,
icon: const Icon(Icons.add_box_outlined, size: 16),
label: Text(_l.addStep),
),
],
),
),
),
),
FlowCanvas(controller: _controller, style: style),
if (!hasSteps) emptyOverlay(),
],
),
),
if (_controller.selectedStepId != null) ...[
if (hasSelection) ...[
const VerticalDivider(width: 1),
SizedBox(
width: 320,
child: PropertiesPanel(
controller: _controller,
strings: _l,
availableCapabilities: widget.availableCapabilities,
),
),
SizedBox(width: 320, child: panel()),
],
],
);