Merge branch 'dev' into cw-1440-new-buy-sell-flow

# Conflicts:
#	lib/src/widgets/cake_image_widget.dart
This commit is contained in:
Robert Malikowski
2026-08-20 18:31:40 +02:00
13 changed files with 239 additions and 117 deletions
+1
View File
@@ -268,6 +268,7 @@ class _ScanPageState extends State<ScanPage> {
),
FloatingIconButton(
iconPath: "assets/new-ui/paste.svg",
semanticLabel: S.of(context).paste,
onPressed: () async {
final data = await Clipboard.getData("text/plain");
if (data?.text != null) {
@@ -17,12 +17,17 @@ class TokenImageWidget extends StatefulWidget {
required this.imageUrl,
required this.size,
this.errorWidget,
this.semanticsLabel,
});
final String imageUrl;
final double size;
final Widget? errorWidget;
/// Accessible name for the token artwork. Leave `null` (the default) when the
/// token is already named by adjacent text, so the image stays decorative.
final String? semanticsLabel;
@override
State<TokenImageWidget> createState() => _TokenImageWidgetState();
}
@@ -132,6 +137,7 @@ class _TokenImageWidgetState extends State<TokenImageWidget> {
fit: BoxFit.cover,
filterQuality: FilterQuality.high,
errorWidget: widget.errorWidget,
semanticsLabel: widget.semanticsLabel,
),
);
@@ -51,85 +51,104 @@ class ReceiveQrCode extends StatelessWidget {
height: 45,
)),
),
GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: targetY),
duration: animDuration,
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Transform.translate(
offset: Offset(0, value),
child: child,
);
},
child: Observer(
builder: (_) => Column(
children: [
AnimatedScale(
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
scale: resolvedScale,
duration: animDuration,
child: AnimatedContainer(
duration: animDuration,
// The QR, and the payjoin badge below it, share one tap target, so they
// are merged into a single button node named by the QR's own label
// rather than each becoming a separate focus stop.
MergeSemantics(
child: Semantics(
hint: largeQrMode ? S.of(context).qr_close_fullscreen : S.of(context).qr_fullscreen,
button: true,
enabled: true,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: targetY),
duration: animDuration,
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Transform.translate(
offset: Offset(0, value),
child: child,
);
},
child: Observer(
builder: (_) => Column(
children: [
AnimatedScale(
curve: Curves.easeOutCubic,
width: resolvedSize,
height: resolvedSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Colors.white,
),
child: Container(
alignment: Alignment.bottomCenter,
scale: resolvedScale,
duration: animDuration,
child: AnimatedContainer(
duration: animDuration,
curve: Curves.easeOutCubic,
width: resolvedSize,
height: resolvedSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
color: addressListViewModel.hasPayjoin && !largeQrMode
? Theme.of(context).colorScheme.surfaceContainer
: Colors.transparent),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Observer(
builder: (_) => QrImage(
data: addressListViewModel.uri.toString(),
size: resolvedSize,
embeddedImagePath:
addressListViewModel.tokenCurrency != null
? addressListViewModel.tokenCurrency ==
CryptoCurrency.btcln
? addressListViewModel.qrImage
: addressListViewModel.tokenCurrency!.iconPath
: addressListViewModel.qrImage,
))),
borderRadius: BorderRadius.circular(20),
color: Colors.white,
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
color: addressListViewModel.hasPayjoin && !largeQrMode
? Theme.of(context).colorScheme.surfaceContainer
: Colors.transparent),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Observer(
builder: (_) => QrImage(
data: addressListViewModel.uri.toString(),
size: resolvedSize,
semanticsLabel: S.of(context).qr_code_receive_address,
embeddedImagePath:
addressListViewModel.tokenCurrency != null
? addressListViewModel.tokenCurrency ==
CryptoCurrency.btcln
? addressListViewModel.qrImage
: addressListViewModel
.tokenCurrency!.iconPath
: addressListViewModel.qrImage,
))),
),
),
),
),
if (addressListViewModel.hasPayjoin)
Opacity(
opacity: largeQrMode ? 0 : 1,
child: Container(
width: resolvedSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.vertical(bottom: Radius.circular(16)),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 4,
children: [
CakeImageWidget(imageUrl: "assets/new-ui/payjoin.svg"),
Text(S.of(context).payjoin_enabled)
],
),
))),
AnimatedSize(
duration: animDuration,
curve: Curves.easeOutCubic,
child: SizedBox(height: largeQrMode ? largeQrModeBottomPadding + 40 : 0))
],
if (addressListViewModel.hasPayjoin)
// Hidden but still mounted in large-QR mode, so keep it
// out of the semantics tree there.
ExcludeSemantics(
excluding: largeQrMode,
child: Opacity(
opacity: largeQrMode ? 0 : 1,
child: Container(
width: resolvedSize,
decoration: BoxDecoration(
borderRadius:
BorderRadius.vertical(bottom: Radius.circular(16)),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 4,
children: [
CakeImageWidget(imageUrl: "assets/new-ui/payjoin.svg"),
Text(S.of(context).payjoin_enabled)
],
),
))),
),
AnimatedSize(
duration: animDuration,
curve: Curves.easeOutCubic,
child: SizedBox(height: largeQrMode ? largeQrModeBottomPadding + 40 : 0))
],
),
),
)),
),
),
),
),
],
);
@@ -3,28 +3,45 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
class FloatingIconButton extends StatelessWidget {
const FloatingIconButton({super.key, required this.iconPath, required this.onPressed});
const FloatingIconButton({
super.key,
required this.iconPath,
required this.onPressed,
required this.semanticLabel,
});
final String iconPath;
final VoidCallback onPressed;
/// Localized accessible name for this icon-only button. The icon itself stays
/// decorative, so this is the only name a screen reader can announce.
final String semanticLabel;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(6)),
child: InkWell(
return MergeSemantics(
child: Semantics(
label: semanticLabel,
button: true,
enabled: true,
child: Material(
color: Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(6)),
onTap: onPressed,
child: Padding(
padding: const EdgeInsets.all(4.0),
child: CakeImageWidget(
imageUrl: iconPath,
width: 22,
height: 22,
colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
)),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(6)),
onTap: onPressed,
child: Padding(
padding: const EdgeInsets.all(4.0),
child: CakeImageWidget(
imageUrl: iconPath,
width: 22,
height: 22,
colorFilter:
ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
)),
),
),
);
}
}
@@ -137,16 +137,19 @@ class _NewSendAddressInputState extends State<NewSendAddressInput> {
SizedBox.shrink(),
FloatingIconButton(
iconPath: "assets/new-ui/paste.svg",
semanticLabel: S.of(context).paste,
onPressed: () async {
_pasteAddress(context);
}),
FloatingIconButton(
iconPath: "assets/new-ui/scan.svg",
semanticLabel: S.of(context).scan_qr_code,
onPressed: () {
_presentQRScanner(context);
}),
FloatingIconButton(
iconPath: "assets/new-ui/contacts_outlined.svg",
semanticLabel: S.of(context).address_book,
onPressed: () {
_presetAddressBookPicker(context);
}),
@@ -97,6 +97,7 @@ class _NewSendAmountInputState extends State<NewSendAmountInput> {
),
FloatingIconButton(
iconPath: "assets/new-ui/paste.svg",
semanticLabel: S.of(context).paste,
onPressed: () async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null && data.text != null) {
@@ -46,6 +46,7 @@ class NewSendMemoInput extends StatelessWidget {
SizedBox(width: 12),
FloatingIconButton(
iconPath: "assets/new-ui/paste.svg",
semanticLabel: S.of(context).paste,
onPressed: () async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null && data.text != null) {
+13 -1
View File
@@ -1,3 +1,4 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:flutter/material.dart';
@@ -13,6 +14,7 @@ class QrImage extends StatelessWidget {
this.errorCorrectionLevel = qr.QrErrorCorrectLevel.H,
this.embeddedImagePath,
this.badgeImageOnEmbeddedImagePath,
this.semanticsLabel,
});
final double? size;
@@ -24,6 +26,11 @@ class QrImage extends StatelessWidget {
final String? embeddedImagePath;
final String? badgeImageOnEmbeddedImagePath;
/// Accessible name announced for the code itself. Defaults to a generic
/// localized "QR code" so the code is always discoverable; pass a more
/// specific label when the host screen knows what the code encodes.
final String? semanticsLabel;
@override
Widget build(BuildContext context) {
final imagePath = embeddedImagePath ?? 'assets/images/qr-cake.png';
@@ -68,8 +75,13 @@ class QrImage extends StatelessWidget {
foregroundColor: foregroundColor,
backgroundColor: backgroundColor,
padding: const EdgeInsets.all(12.0),
// The package default is a hardcoded English "qr code"; use the
// localized string so the code is announced in the user's language.
semanticsLabel: semanticsLabel ?? S.of(context).scan_qr_code,
),
centerImageToUse,
// The embedded logo and its badge are purely decorative — the node
// above already announces the code — so keep them out of the tree.
ExcludeSemantics(child: centerImageToUse),
],
);
}
+47 -13
View File
@@ -18,6 +18,7 @@ class CakeImageWidget extends StatelessWidget {
this.alignment,
this.allowDrawingOutsideViewBox,
this.filterQuality,
this.semanticsLabel,
});
final String? imageUrl;
@@ -33,6 +34,16 @@ class CakeImageWidget extends StatelessWidget {
final double borderRadius;
final FilterQuality? filterQuality;
/// Accessible name for this image.
///
/// Leave `null` (the default) for decorative imagery — the image then
/// contributes nothing at all to the semantics tree, so screen readers do not
/// stop on an unnamed node. Pass a localized string only when the image is the
/// sole carrier of information for the user.
final String? semanticsLabel;
bool get _isDecorative => semanticsLabel == null;
@override
Widget build(BuildContext context) {
if (imageUrl == null || imageUrl!.isEmpty) {
@@ -53,6 +64,8 @@ class CakeImageWidget extends StatelessWidget {
alignment: alignment ?? Alignment.center,
allowDrawingOutsideViewBox: allowDrawingOutsideViewBox ?? false,
colorFilter: effectiveColorFilter,
semanticsLabel: semanticsLabel,
excludeFromSemantics: _isDecorative,
fit: fit ?? BoxFit.contain, errorBuilder: (context, e, trace) {
return SvgPicture.asset(
imageUrl!,
@@ -62,6 +75,8 @@ class CakeImageWidget extends StatelessWidget {
width: width,
errorBuilder: (_, __, ___) => SizedBox(height: height, width: width),
colorFilter: effectiveColorFilter,
semanticsLabel: semanticsLabel,
excludeFromSemantics: _isDecorative,
fit: fit ?? BoxFit.contain,
);
});
@@ -73,6 +88,8 @@ class CakeImageWidget extends StatelessWidget {
fit: fit,
color: color,
filterQuality: filterQuality ?? FilterQuality.medium,
semanticLabel: semanticsLabel,
excludeFromSemantics: _isDecorative,
errorBuilder: (_, __, ___) => _buildErrorWidget(context),
);
}
@@ -86,13 +103,9 @@ class CakeImageWidget extends StatelessWidget {
alignment: alignment ?? Alignment.center,
allowDrawingOutsideViewBox: allowDrawingOutsideViewBox ?? false,
fit: fit ?? BoxFit.contain,
placeholderBuilder: (_) {
return loadingWidget ??
SizedBox(
height: height,
width: width,
child: Center(child: CupertinoActivityIndicator()));
},
semanticsLabel: semanticsLabel,
excludeFromSemantics: _isDecorative,
placeholderBuilder: (_) => _buildLoadingWidget(),
errorBuilder: (_, __, ___) => _buildErrorWidget(context),
)
: Image.network(
@@ -102,13 +115,11 @@ class CakeImageWidget extends StatelessWidget {
fit: fit ?? BoxFit.cover,
color: color,
filterQuality: filterQuality ?? FilterQuality.medium,
semanticLabel: semanticsLabel,
excludeFromSemantics: _isDecorative,
loadingBuilder: (_, Widget child, ImageChunkEvent? progress) {
if (progress == null) return child;
return loadingWidget ??
SizedBox(
height: height,
width: width,
child: Center(child: CupertinoActivityIndicator()));
return _buildLoadingWidget();
},
errorBuilder: (_, __, ___) => _buildErrorWidget(context),
);
@@ -117,8 +128,16 @@ class CakeImageWidget extends StatelessWidget {
return imageWidget;
}
/// A caller-supplied [loadingWidget] owns its own semantics; the built-in
/// spinner is purely visual and must not become an unnamed focus stop.
Widget _buildLoadingWidget() =>
loadingWidget ?? const ExcludeSemantics(child:SizedBox(
height: height,
width: width,
child: Center(child: CupertinoActivityIndicator())));
Widget _buildErrorWidget(BuildContext context) {
return Container(
final Widget placeholder = Container(
height: height,
width: width,
decoration: BoxDecoration(
@@ -134,5 +153,20 @@ class CakeImageWidget extends StatelessWidget {
),
),
);
// Several call sites render meaningful content (e.g. the asset's initials) as
// their [errorWidget], so leave its semantics to the caller.
if (errorWidget != null) {
return placeholder;
}
return _isDecorative
? ExcludeSemantics(child: placeholder)
: Semantics(
container: true,
image: true,
label: semanticsLabel,
child: ExcludeSemantics(child: placeholder),
);
}
}
+7 -1
View File
@@ -9,7 +9,9 @@ class RoundedCheckbox extends StatelessWidget {
@override
Widget build(BuildContext context) {
return value
// Unchecked renders nothing at all, so without an explicit `checked` state
// the selection is invisible to screen readers.
final Widget indicator = value
? Container(
height: 20.0,
width: 20.0,
@@ -23,5 +25,9 @@ class RoundedCheckbox extends StatelessWidget {
size: 14.0,
))
: Offstage();
// Deliberately not a semantics container: the state merges into the
// enclosing row/option node instead of adding a second focus stop.
return Semantics(checked: value, child: indicator);
}
}
+31 -12
View File
@@ -80,13 +80,26 @@ class AddressFormatter {
spans.add(TextSpan(text: '${chunks[i]} ', style: style));
}
return RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
return _withAddressSemantics(
address: address,
child: RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
),
);
}
/// The visual segmentation turns an address into space-separated pseudo-words
/// (and, when truncated, into a literal "..."), which cannot be used to verify
/// an address by ear. Announce the full address instead, exactly the way
/// `Text.semanticsLabel` does.
static Widget _withAddressSemantics({required String address, required Widget child}) =>
Semantics(
label: address,
child: ExcludeSemantics(child: child),
);
static Widget _buildTruncatedAddress({
required String address,
required bool isMWEB,
@@ -112,10 +125,13 @@ class AddressFormatter {
TextSpan(text: lastChunk, style: evenTextStyle),
];
return RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
return _withAddressSemantics(
address: address,
child: RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
),
);
} else {
final int digitCount = chunkSize;
@@ -142,10 +158,13 @@ class AddressFormatter {
TextSpan(text: lastPart, style: evenTextStyle),
];
return RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
return _withAddressSemantics(
address: address,
child: RichText(
text: TextSpan(children: spans),
textAlign: textAlign ?? TextAlign.start,
overflow: TextOverflow.visible,
),
);
}
}