Files
hash-wallet/lib/new-ui/widgets/line_tab_switcher.dart
T
Seth For PrivacyandGitHub a6bcf7c97a a11y: dashboard and navigation semantics (navbar, cards, sync, switchers) (#3468)
* Label new-UI dashboard navigation and sync status for screen readers

Each bottom-nav tab becomes one button node carrying its localized name, its
selected state and mutual exclusion; the pill text no longer duplicates the
selected tab. The assets/history tabs gain selected state, the Bitcoin/Lightning
switcher becomes a labeled toggle, and the sync bar becomes a single button whose
label is the localized status (plus Tor/MWEB/Silent Payments badges) with a hint
pointing at node management. Compact mode's pulsing dot and the chain icon's
progress ring now have text equivalents, and the invisible pointer-absorbing
strip behind the nav bar is excluded from traversal.

* Make new-UI dashboard cards, lists and menus operable by screen readers

Collapse the wallet-name row into one labeled button (hardware-wallet glyph
folded into the label, inner accounts button no longer a second stop) and give
balance cards a labeled, selectable node with a long-press action for show/hide
balance. Balances hidden behind AnimatedOpacity, card artwork and decorative
glyphs leave the semantics tree; the 3-dots customize target and the card action
buttons are now named buttons. Assets/history rows, the history header, the MWEB
promo, long-press menu items, the account cards and the add-account and
generate-name controls become single button nodes, the phantom action chip in the
assets header is built conditionally instead of drawn at opacity 0, and app cards
get a close tooltip plus an 'opens externally' hint.

* Use expression bodies for the new a11y helpers

* Restore untouched expressions in cards_view

* Address review: state-aware balance hint, tooltip-free close label

* Address review: keep header height without the chip, name the selected index
2026-08-03 19:58:51 +02:00

163 lines
5.0 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class LineTabSwitcher extends StatefulWidget {
const LineTabSwitcher({
super.key,
required this.tabs,
required this.onTabChange,
required this.selectedTab,
});
final List<String> tabs;
final void Function(int index) onTabChange;
final int selectedTab;
@override
State<LineTabSwitcher> createState() => _LineTabSwitcherState();
}
class _LineTabSwitcherState extends State<LineTabSwitcher> {
List<GlobalKey> textWidgetKeys = [];
List<Size> textWidgetSizes = [];
double totalWidth = 0;
final double itemPadding = 16;
final double barPadding = 8;
bool textWidgetsMeasured = false;
@override
void didUpdateWidget(covariant LineTabSwitcher oldWidget) {
super.didUpdateWidget(oldWidget);
if (!listEquals(oldWidget.tabs, widget.tabs)) {
textWidgetKeys = List.generate(widget.tabs.length, (_) => GlobalKey());
textWidgetSizes = [];
totalWidth = 0;
textWidgetsMeasured = false;
WidgetsBinding.instance.addPostFrameCallback((_) => measure());
}
}
double _calcBarLeft() {
double left = 0;
if (textWidgetKeys.isEmpty || textWidgetSizes.isEmpty) {
return 0;
}
for (int i = 0; i < widget.selectedTab; i++) {
left += textWidgetSizes[i].width + itemPadding;
}
left += barPadding;
return left;
}
@override
void initState() {
super.initState();
textWidgetKeys = List.generate(widget.tabs.length, (index) => GlobalKey());
WidgetsBinding.instance.addPostFrameCallback((_) => measure());
}
void measure() {
if (textWidgetsMeasured) return;
setState(() {
textWidgetSizes =
textWidgetKeys.map((k) => k.currentContext!.size).whereType<Size>().toList();
for (final size in textWidgetSizes) {
totalWidth += size.width + itemPadding;
}
totalWidth += barPadding;
textWidgetsMeasured = true;
});
}
@override
Widget build(BuildContext context) {
if (!textWidgetsMeasured) {
WidgetsBinding.instance.addPostFrameCallback((_) => measure());
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
height: 28,
child: Row(
children: widget.tabs.map((item) {
final index = widget.tabs.indexOf(item);
// One tab = one node: the visible label merges into a button node
// that also carries the selected state of this tab.
return MergeSemantics(
child: Semantics(
button: true,
selected: widget.selectedTab == index,
inMutuallyExclusiveGroup: true,
child: GestureDetector(
onTap: () {
widget.onTabChange(index);
},
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedDefaultTextStyle(
duration: Duration(milliseconds: 150),
style: DefaultTextStyle.of(context).style.copyWith(
inherit: true,
fontSize: 16,
fontWeight: FontWeight.w500,
color: widget.selectedTab == index
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSurfaceVariant,
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: itemPadding / 2),
child: Text(
item,
key: textWidgetKeys[index],
),
),
),
],
),
),
),
);
}).toList())),
if (widget.tabs.length > 1)
Container(
width: totalWidth,
height: 2,
child: Stack(
children: [
AnimatedPositioned(
curve: Curves.easeOut,
left: _calcBarLeft(),
bottom: 0,
duration: Duration(milliseconds: 150),
child: AnimatedSize(
duration: Duration(milliseconds: 150),
child: Container(
height: 2,
width:
textWidgetSizes.isEmpty ? 0 : textWidgetSizes[widget.selectedTab].width,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
],
),
),
],
);
}
}