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
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
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_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';
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
101
test/chain_segments_test.dart
Normal file
101
test/chain_segments_test.dart
Normal 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')}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue