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
|
|
@ -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)';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
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