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

@ -6,6 +6,20 @@ lockstep.
## Unreleased
### Changed (0.75.0)
- **One segment control everywhere.** The same single-select
choice pattern used to appear as four different widgets: the
audit page's hover pills, the store's Material SegmentedButton
(Modules / Studio & Designs), the store filter dialog's
ChoiceChips and the approvals page's TabBar. All of them now use
the canonical `ChainSegments` pill control (the audit pattern
promoted to a shared widget, with icons and a11y semantics);
the approvals page keeps both lists alive in an IndexedStack so
switching does not refetch. A canon-guard test bans
TabBar/SegmentedButton/ChoiceChip from lib/ for good. Guide
images + wording updated (dark and light verified).
### Hardening round (test strategy, 2026-07-19)
- **Hermetic widget suites.** `HubService.instance` is injectable;

View file

@ -4,7 +4,7 @@
/// Studio's own build version. Bump on every UI release so the
/// running app self-identifies.
const String kStudioVersion = '0.74.0';
const String kStudioVersion = '0.75.0';
const String kProductName = 'Ch∆In Studio';
const String kVendorName = 'Flemming.AI (F∆I)';

View file

@ -36,9 +36,12 @@ class ApprovalsPage extends StatefulWidget {
State<ApprovalsPage> createState() => _ApprovalsPageState();
}
class _ApprovalsPageState extends State<ApprovalsPage>
with SingleTickerProviderStateMixin {
late final TabController _tab;
class _ApprovalsPageState extends State<ApprovalsPage> {
/// 0 = pending inbox, 1 = history. Plain index instead of a
/// TabController: the page uses the canonical pill segment
/// (ChainSegments), and an IndexedStack keeps both lists alive
/// so switching does not refetch.
int _tabIndex = 0;
late Future<List<ApprovalRecord>> _pendingFuture;
late Future<List<ApprovalRecord>> _historyFuture;
@ -63,7 +66,6 @@ class _ApprovalsPageState extends State<ApprovalsPage>
@override
void initState() {
super.initState();
_tab = TabController(length: 2, vsync: this);
Workspace.instance.addListener(_refresh);
Workspace.instance.ensureLoaded();
_refresh();
@ -72,7 +74,6 @@ class _ApprovalsPageState extends State<ApprovalsPage>
@override
void dispose() {
Workspace.instance.removeListener(_refresh);
_tab.dispose();
super.dispose();
}
@ -262,12 +263,33 @@ class _ApprovalsPageState extends State<ApprovalsPage>
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.approvalsTitle),
bottom: TabBar(
controller: _tab,
tabs: [
Tab(text: AppLocalizations.of(context)!.approvalsTabPending),
Tab(text: AppLocalizations.of(context)!.approvalsTabHistory),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(44),
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.fromLTRB(
ChainSpace.xl,
0,
ChainSpace.xl,
ChainSpace.sm,
),
child: ChainSegments<int>(
items: [
ChainSegmentItem(
0,
AppLocalizations.of(context)!.approvalsTabPending,
),
ChainSegmentItem(
1,
AppLocalizations.of(context)!.approvalsTabHistory,
),
],
value: _tabIndex,
onChanged: (i) => setState(() => _tabIndex = i),
),
),
),
),
actions: [
const ChainWorkspaceSwitcher(),
@ -285,8 +307,8 @@ class _ApprovalsPageState extends State<ApprovalsPage>
const SizedBox(width: ChainSpace.sm),
],
),
body: TabBarView(
controller: _tab,
body: IndexedStack(
index: _tabIndex,
children: [
_PendingList(
future: _pendingFuture,

View file

@ -634,86 +634,10 @@ class _FilterChips extends StatelessWidget {
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final items = _filterItems(l);
return Row(
children: [
for (final (v, label) in items)
Padding(
padding: const EdgeInsets.only(left: ChainSpace.xs),
child: _ChipButton(
label: label,
selected: value == v,
onTap: () => onChanged(v),
),
),
],
);
}
}
class _ChipButton extends StatefulWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const _ChipButton({
required this.label,
required this.selected,
required this.onTap,
});
@override
State<_ChipButton> createState() => _ChipButtonState();
}
class _ChipButtonState extends State<_ChipButton> {
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 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: Text(
widget.label,
// UI label, not a value mono is reserved for
// paths/identifiers (usertest finding: the chips
// read as foreign bodies in the header).
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: widget.selected ? FontWeight.w600 : FontWeight.w400,
color: fg,
),
),
),
),
return ChainSegments<String>(
items: [for (final (v, label) in items) ChainSegmentItem(v, label)],
value: value,
onChanged: onChanged,
);
}
}

View file

@ -365,25 +365,22 @@ class _StorePageState extends State<StorePage> {
Padding(
padding:
const EdgeInsets.only(bottom: ChainSpace.md),
child: SegmentedButton<bool>(
segments: [
ButtonSegment(
value: false,
label: Text(l.storeSegmentModules),
icon: const Icon(Icons.extension_outlined,
size: 16),
child: ChainSegments<bool>(
items: [
ChainSegmentItem(
false,
l.storeSegmentModules,
icon: Icons.extension_outlined,
),
ButtonSegment(
value: true,
label: Text(l.storeSegmentStudio),
icon:
const Icon(Icons.palette_outlined, size: 16),
ChainSegmentItem(
true,
l.storeSegmentStudio,
icon: Icons.palette_outlined,
),
],
selected: {_showStudio},
showSelectedIcon: false,
onSelectionChanged: (s) =>
setState(() => _showStudio = s.first),
value: _showStudio,
onChanged: (v) =>
setState(() => _showStudio = v),
),
),
if (_aiThinking ||
@ -1161,17 +1158,10 @@ class _FilterDialogState extends State<_FilterDialog> {
List<({String value, String label})> items,
String selected,
void Function(String) onSelect,
) => Wrap(
spacing: ChainSpace.xs,
runSpacing: ChainSpace.xs,
children: [
for (final i in items)
_ChoiceChip(
label: i.label,
selected: selected == i.value,
onSelected: () => onSelect(i.value),
),
],
) => ChainSegments<String>(
items: [for (final i in items) ChainSegmentItem(i.value, i.label)],
value: selected,
onChanged: onSelect,
);
return AlertDialog(
title: Text(l.storeFilterLabel),
@ -1569,31 +1559,6 @@ Up to 5 matches, ordered by best fit first. Use only module names that appear in
}
}
class _ChoiceChip extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onSelected;
const _ChoiceChip({
required this.label,
required this.selected,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: ChainSpace.xs),
child: ChoiceChip(
label: Text(label),
selected: selected,
onSelected: (_) => onSelected(),
showCheckmark: false,
),
);
}
}
/// Responsive grid wraps to as many columns as the viewport
/// allows. Cards are click-to-expand; install is also a top-
/// level action so single-click installs are still possible.

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';

View file

@ -1,7 +1,7 @@
name: chain_studio
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
publish_to: 'none'
version: 0.74.0
version: 0.75.0
environment:
sdk: ^3.11.0-200.1.beta

View file

@ -0,0 +1,101 @@
// ChainSegments the canonical single-select segment control
// (night-log decision "pill segment as canon"; usertest finding
// #14: the same choice pattern appeared as four different
// widgets). Guards both the widget behaviour and the canon
// itself: no page may reintroduce TabBar / SegmentedButton /
// ChoiceChip for single-select segments.
import 'dart:io';
import 'package:flutter/material.dart';
import 'dart:ui' show Tristate;
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio/widgets/chain_segments.dart';
Widget _host(Widget child) =>
MaterialApp(home: Scaffold(body: Center(child: child)));
void main() {
testWidgets('tapping a segment reports the new value once', (tester) async {
final changes = <String>[];
await tester.pumpWidget(
_host(
ChainSegments<String>(
items: const [
ChainSegmentItem('all', 'Alle'),
ChainSegmentItem('flow', 'Flow'),
ChainSegmentItem('step', 'Schritt'),
],
value: 'all',
onChanged: changes.add,
),
),
);
await tester.tap(find.text('Flow'));
await tester.pump();
expect(changes, ['flow']);
// Tapping the already-selected value must NOT re-fire call
// sites use the callback to trigger refetches.
await tester.tap(find.text('Alle'));
await tester.pump();
expect(changes, ['flow']);
});
testWidgets('segments expose button + selected semantics', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(
_host(
ChainSegments<int>(
items: const [
ChainSegmentItem(0, 'Ausstehend'),
ChainSegmentItem(1, 'Verlauf'),
],
value: 0,
onChanged: (_) {},
),
),
);
final pending = tester.getSemantics(find.text('Ausstehend'));
expect(pending.flagsCollection.isSelected, Tristate.isTrue);
final history = tester.getSemantics(find.text('Verlauf'));
expect(history.flagsCollection.isSelected, Tristate.isFalse);
handle.dispose();
});
test('canon guard: no page reintroduces TabBar/SegmentedButton/ChoiceChip',
() {
// The night-log decision made the pill segment the ONE way to
// render a single-select segment. This sweep keeps the three
// retired Material patterns out of lib/ for good (comments
// don't count; the widget file itself is exempt).
final offenders = <String>[];
final banned = RegExp(
r'\b(TabBar|TabBarView|TabController|SegmentedButton|ChoiceChip)\b',
);
final files = Directory('lib')
.listSync(recursive: true)
.whereType<File>()
.where((f) => f.path.endsWith('.dart'))
.where((f) => !f.path.contains('chain_segments.dart'));
for (final f in files) {
final lines = f.readAsLinesSync();
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
final code = line.contains('//')
? line.substring(0, line.indexOf('//'))
: line;
if (banned.hasMatch(code)) {
offenders.add('${f.path}:${i + 1}: ${line.trim()}');
}
}
}
expect(
offenders,
isEmpty,
reason:
'Single-select segments use ChainSegments (canon). '
'Found retired patterns:\n${offenders.join('\n')}',
);
});
}