From ed680c507a48fd39adf8f5463fac94d3d9b23739 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 19 Jul 2026 02:58:54 +0200 Subject: [PATCH] refactor(ui): one canonical segment control (ChainSegments) everywhere 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 --- CHANGELOG.md | 14 ++++ lib/data/about_info.dart | 2 +- lib/pages/approvals.dart | 48 ++++++++--- lib/pages/audit.dart | 84 +------------------- lib/pages/store.dart | 69 ++++------------ lib/widgets/chain_segments.dart | 137 ++++++++++++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + pubspec.yaml | 2 +- test/chain_segments_test.dart | 101 +++++++++++++++++++++++ 9 files changed, 311 insertions(+), 147 deletions(-) create mode 100644 lib/widgets/chain_segments.dart create mode 100644 test/chain_segments_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e33bf2a..804e815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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; diff --git a/lib/data/about_info.dart b/lib/data/about_info.dart index af79ecf..d618851 100644 --- a/lib/data/about_info.dart +++ b/lib/data/about_info.dart @@ -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)'; diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index 59bacdb..49e1b2a 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -36,9 +36,12 @@ class ApprovalsPage extends StatefulWidget { State createState() => _ApprovalsPageState(); } -class _ApprovalsPageState extends State - with SingleTickerProviderStateMixin { - late final TabController _tab; +class _ApprovalsPageState extends State { + /// 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> _pendingFuture; late Future> _historyFuture; @@ -63,7 +66,6 @@ class _ApprovalsPageState extends State @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 @override void dispose() { Workspace.instance.removeListener(_refresh); - _tab.dispose(); super.dispose(); } @@ -262,12 +263,33 @@ class _ApprovalsPageState extends State 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( + 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 const SizedBox(width: ChainSpace.sm), ], ), - body: TabBarView( - controller: _tab, + body: IndexedStack( + index: _tabIndex, children: [ _PendingList( future: _pendingFuture, diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 0163978..f8394ff 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -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( + items: [for (final (v, label) in items) ChainSegmentItem(v, label)], + value: value, + onChanged: onChanged, ); } } diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 0aead7d..7776fc0 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -365,25 +365,22 @@ class _StorePageState extends State { Padding( padding: const EdgeInsets.only(bottom: ChainSpace.md), - child: SegmentedButton( - segments: [ - ButtonSegment( - value: false, - label: Text(l.storeSegmentModules), - icon: const Icon(Icons.extension_outlined, - size: 16), + child: ChainSegments( + 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( + 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. diff --git a/lib/widgets/chain_segments.dart b/lib/widgets/chain_segments.dart new file mode 100644 index 0000000..51ade82 --- /dev/null +++ b/lib/widgets/chain_segments.dart @@ -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 { + final T value; + final String label; + final IconData? icon; + + const ChainSegmentItem(this.value, this.label, {this.icon}); +} + +class ChainSegments extends StatelessWidget { + final List> items; + final T value; + final ValueChanged 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, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index dd45bb0..1de0fec 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -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'; diff --git a/pubspec.yaml b/pubspec.yaml index 1c8dcbc..01aa360 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/chain_segments_test.dart b/test/chain_segments_test.dart new file mode 100644 index 0000000..db29fea --- /dev/null +++ b/test/chain_segments_test.dart @@ -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 = []; + await tester.pumpWidget( + _host( + ChainSegments( + 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( + 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 = []; + final banned = RegExp( + r'\b(TabBar|TabBarView|TabController|SegmentedButton|ChoiceChip)\b', + ); + final files = Directory('lib') + .listSync(recursive: true) + .whereType() + .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')}', + ); + }); +}