refactor(ui): one canonical segment control (ChainSegments) everywhere
Some checks failed
Security / Security check (push) Failing after 1s

The same single-select choice pattern appeared as four widgets:
audit's hover pills, the store's SegmentedButton, the store filter
dialog's ChoiceChips, and the approvals TabBar (usertest finding
#14 / night-log decision 'pill segment as canon'). The audit
pattern is promoted to a shared ChainSegments widget (optional
icons, hover, selected border, button+selected semantics) and all
four sites use it; approvals switches lists via IndexedStack so
both stay alive and switching does not refetch.

Guard per the no-bugfix-without-a-guard rule: widget tests for
selection + semantics, plus a canon sweep that bans
TabBar/TabBarView/TabController/SegmentedButton/ChoiceChip from
lib/ (comments exempt). Deliberately out of scope: the flow
editor's Graph/Text/Run tabs live in the separate editor package.
Studio 0.75.0; guide images regenerated, dark + light verified.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-19 02:58:54 +02:00
parent bfd58baa75
commit ed680c507a
9 changed files with 311 additions and 147 deletions

View file

@ -0,0 +1,137 @@
// ChainSegments THE single-select segment control (usertest
// finding #14 / night-log decision "pill segment as canon"):
// the same choice pattern used to appear as four different
// widgets (audit's hover pills, store's Material SegmentedButton,
// store-filter ChoiceChips, approvals' TabBar). One look now:
// text pills, primary-tinted when selected, quiet hover the
// audit page's pattern promoted to a shared widget.
//
// Single-select only by design. Multi-select filters are a
// different pattern (checkbox list), not a segment control.
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
class ChainSegmentItem<T> {
final T value;
final String label;
final IconData? icon;
const ChainSegmentItem(this.value, this.label, {this.icon});
}
class ChainSegments<T> extends StatelessWidget {
final List<ChainSegmentItem<T>> items;
final T value;
final ValueChanged<T> onChanged;
const ChainSegments({
super.key,
required this.items,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: ChainSpace.xs,
runSpacing: ChainSpace.xs,
children: [
for (final item in items)
_SegmentPill(
label: item.label,
icon: item.icon,
selected: item.value == value,
onTap: () {
if (item.value != value) onChanged(item.value);
},
),
],
);
}
}
class _SegmentPill extends StatefulWidget {
final String label;
final IconData? icon;
final bool selected;
final VoidCallback onTap;
const _SegmentPill({
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
@override
State<_SegmentPill> createState() => _SegmentPillState();
}
class _SegmentPillState extends State<_SegmentPill> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fg = widget.selected
? theme.colorScheme.primary
: _hovered
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
final bg = widget.selected
? theme.colorScheme.primary.withValues(alpha: 0.12)
: _hovered
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent;
return Semantics(
button: true,
selected: widget.selected,
label: widget.label,
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: ChainMotion.fast,
padding: const EdgeInsets.symmetric(
horizontal: ChainSpace.md,
vertical: 6,
),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(ChainRadius.sm),
border: Border.all(
color: widget.selected
? theme.colorScheme.primary.withValues(alpha: 0.3)
: Colors.transparent,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.icon != null) ...[
Icon(widget.icon, size: 14, color: fg),
const SizedBox(width: ChainSpace.xs),
],
Text(
widget.label,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: widget.selected
? FontWeight.w600
: FontWeight.w400,
color: fg,
),
),
],
),
),
),
),
);
}
}

View file

@ -11,6 +11,7 @@ export 'chain_delta_mark.dart';
export 'chain_empty_state.dart';
export 'chain_en_badge.dart';
export 'chain_error_box.dart';
export 'chain_segments.dart';
export 'hub_load_error_view.dart';
export 'chain_flow_output.dart';
export 'chain_install_confirm.dart';