refactor(ui): one canonical segment control (ChainSegments) everywhere
Some checks failed
Security / Security check (push) Failing after 1s
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:
parent
bfd58baa75
commit
ed680c507a
9 changed files with 311 additions and 147 deletions
137
lib/widgets/chain_segments.dart
Normal file
137
lib/widgets/chain_segments.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue