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 ## 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) ### Hardening round (test strategy, 2026-07-19)
- **Hermetic widget suites.** `HubService.instance` is injectable; - **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 /// Studio's own build version. Bump on every UI release so the
/// running app self-identifies. /// running app self-identifies.
const String kStudioVersion = '0.74.0'; const String kStudioVersion = '0.75.0';
const String kProductName = 'Ch∆In Studio'; const String kProductName = 'Ch∆In Studio';
const String kVendorName = 'Flemming.AI (F∆I)'; const String kVendorName = 'Flemming.AI (F∆I)';

View file

@ -36,9 +36,12 @@ class ApprovalsPage extends StatefulWidget {
State<ApprovalsPage> createState() => _ApprovalsPageState(); State<ApprovalsPage> createState() => _ApprovalsPageState();
} }
class _ApprovalsPageState extends State<ApprovalsPage> class _ApprovalsPageState extends State<ApprovalsPage> {
with SingleTickerProviderStateMixin { /// 0 = pending inbox, 1 = history. Plain index instead of a
late final TabController _tab; /// 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>> _pendingFuture;
late Future<List<ApprovalRecord>> _historyFuture; late Future<List<ApprovalRecord>> _historyFuture;
@ -63,7 +66,6 @@ class _ApprovalsPageState extends State<ApprovalsPage>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tab = TabController(length: 2, vsync: this);
Workspace.instance.addListener(_refresh); Workspace.instance.addListener(_refresh);
Workspace.instance.ensureLoaded(); Workspace.instance.ensureLoaded();
_refresh(); _refresh();
@ -72,7 +74,6 @@ class _ApprovalsPageState extends State<ApprovalsPage>
@override @override
void dispose() { void dispose() {
Workspace.instance.removeListener(_refresh); Workspace.instance.removeListener(_refresh);
_tab.dispose();
super.dispose(); super.dispose();
} }
@ -262,12 +263,33 @@ class _ApprovalsPageState extends State<ApprovalsPage>
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar( appBar: AppBar(
title: Text(AppLocalizations.of(context)!.approvalsTitle), title: Text(AppLocalizations.of(context)!.approvalsTitle),
bottom: TabBar( bottom: PreferredSize(
controller: _tab, preferredSize: const Size.fromHeight(44),
tabs: [ child: Align(
Tab(text: AppLocalizations.of(context)!.approvalsTabPending), alignment: Alignment.centerLeft,
Tab(text: AppLocalizations.of(context)!.approvalsTabHistory), 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: [ actions: [
const ChainWorkspaceSwitcher(), const ChainWorkspaceSwitcher(),
@ -285,8 +307,8 @@ class _ApprovalsPageState extends State<ApprovalsPage>
const SizedBox(width: ChainSpace.sm), const SizedBox(width: ChainSpace.sm),
], ],
), ),
body: TabBarView( body: IndexedStack(
controller: _tab, index: _tabIndex,
children: [ children: [
_PendingList( _PendingList(
future: _pendingFuture, future: _pendingFuture,

View file

@ -634,86 +634,10 @@ class _FilterChips extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final items = _filterItems(l); final items = _filterItems(l);
return Row( return ChainSegments<String>(
children: [ items: [for (final (v, label) in items) ChainSegmentItem(v, label)],
for (final (v, label) in items) value: value,
Padding( onChanged: onChanged,
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,
),
),
),
),
); );
} }
} }

View file

@ -365,25 +365,22 @@ class _StorePageState extends State<StorePage> {
Padding( Padding(
padding: padding:
const EdgeInsets.only(bottom: ChainSpace.md), const EdgeInsets.only(bottom: ChainSpace.md),
child: SegmentedButton<bool>( child: ChainSegments<bool>(
segments: [ items: [
ButtonSegment( ChainSegmentItem(
value: false, false,
label: Text(l.storeSegmentModules), l.storeSegmentModules,
icon: const Icon(Icons.extension_outlined, icon: Icons.extension_outlined,
size: 16),
), ),
ButtonSegment( ChainSegmentItem(
value: true, true,
label: Text(l.storeSegmentStudio), l.storeSegmentStudio,
icon: icon: Icons.palette_outlined,
const Icon(Icons.palette_outlined, size: 16),
), ),
], ],
selected: {_showStudio}, value: _showStudio,
showSelectedIcon: false, onChanged: (v) =>
onSelectionChanged: (s) => setState(() => _showStudio = v),
setState(() => _showStudio = s.first),
), ),
), ),
if (_aiThinking || if (_aiThinking ||
@ -1161,17 +1158,10 @@ class _FilterDialogState extends State<_FilterDialog> {
List<({String value, String label})> items, List<({String value, String label})> items,
String selected, String selected,
void Function(String) onSelect, void Function(String) onSelect,
) => Wrap( ) => ChainSegments<String>(
spacing: ChainSpace.xs, items: [for (final i in items) ChainSegmentItem(i.value, i.label)],
runSpacing: ChainSpace.xs, value: selected,
children: [ onChanged: onSelect,
for (final i in items)
_ChoiceChip(
label: i.label,
selected: selected == i.value,
onSelected: () => onSelect(i.value),
),
],
); );
return AlertDialog( return AlertDialog(
title: Text(l.storeFilterLabel), 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 /// Responsive grid wraps to as many columns as the viewport
/// allows. Cards are click-to-expand; install is also a top- /// allows. Cards are click-to-expand; install is also a top-
/// level action so single-click installs are still possible. /// 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_empty_state.dart';
export 'chain_en_badge.dart'; export 'chain_en_badge.dart';
export 'chain_error_box.dart'; export 'chain_error_box.dart';
export 'chain_segments.dart';
export 'hub_load_error_view.dart'; export 'hub_load_error_view.dart';
export 'chain_flow_output.dart'; export 'chain_flow_output.dart';
export 'chain_install_confirm.dart'; export 'chain_install_confirm.dart';

View file

@ -1,7 +1,7 @@
name: chain_studio name: chain_studio
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
publish_to: 'none' publish_to: 'none'
version: 0.74.0 version: 0.75.0
environment: environment:
sdk: ^3.11.0-200.1.beta 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')}',
);
});
}