mobile: tap the settings version footer to copy it (#9021)

## Summary

The version line at the bottom of mobile Settings (for example `v0.27.0
(36)`) couldn't be copied, which made it awkward to paste into bug
reports. Tapping it now copies the whole string and shows a "Version
copied" toast, matching the existing "Copy public key" row. It's
keyboard-focusable and has a 48dp tap target. Partial selection isn't
supported; it's whole-string only.

![Settings after tapping the version: a Version copied
toast](https://github.com/user-attachments/assets/007a10ed-61e5-47a2-b0a3-1f09f522e565)

### Related issue

N/A

### Validation

- Added test: tapping the footer, or reaching it with Tab and pressing
Enter, puts `v0.16.0 (432)` on the clipboard and shows the toast. The
test also checks that the tap target is at least 48dp tall.
- `just mobile-check` and `just mobile-test` pass.
- Automated simulator run (debug build, iOS 27): opened Settings, tapped
the version, confirmed via `simctl pbpaste` that the clipboard held the
exact footer text and the toast appeared; then confirmed the neighboring
"Copy public key" row still copies the npub and Settings still closes.
Human testing waived per the mobile testing exception. A full recording
was posted in Buzz instead of here because it shows internal channel
names.

🤖 Generated with [Claude Code](https://claude.com/claude-code)



buzz-review-completed

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-03 18:39:44 -08:00
committed by GitHub
parent 3d26ed7dfd
commit 38a5d9a1ff
2 changed files with 106 additions and 7 deletions

View File

@@ -150,15 +150,41 @@ class _VersionFooter extends StatelessWidget {
@override
Widget build(BuildContext context) {
final label = buildNumber.isEmpty
? 'v$version'
: 'v$version ($buildNumber)';
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.only(bottom: Grid.xs, top: Grid.xxs),
child: Center(
child: Text(
buildNumber.isEmpty ? 'v$version' : 'v$version ($buildNumber)',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.6),
child: Center(
child: Semantics(
button: true,
onTapHint: 'Copy version',
child: InkWell(
borderRadius: BorderRadius.circular(Grid.xs),
onTap: () =>
copyToClipboard(context, label, message: 'Version copied'),
// Keep the text where it was and grow the tap target downward
// to the platform minimum.
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: kMinInteractiveDimension,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.xs,
Grid.xxs,
Grid.xs,
Grid.xs,
),
child: Text(
label,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.6,
),
),
),
),
),
),
),

View File

@@ -92,6 +92,79 @@ void main() {
});
}
for (final viaKeyboard in [false, true]) {
testWidgets(
'${viaKeyboard ? 'keyboard activation of' : 'tapping'} the version copies the whole version string',
(tester) async {
String? copied;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'Clipboard.setData') {
copied = (call.arguments as Map)['text'] as String?;
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
child: MaterialApp(
theme: AppTheme.light(),
home: SettingsPage(
profileHeader: const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),
),
);
await tester.pumpAndSettle();
final version = find.text('v0.16.0 (432)');
await tester.scrollUntilVisible(version, 200);
await tester.pumpAndSettle();
final target = find.ancestor(
of: version,
matching: find.byType(InkWell),
);
expect(
tester.getSize(target).height,
greaterThanOrEqualTo(kMinInteractiveDimension),
);
if (viaKeyboard) {
final versionFocus = Focus.of(tester.element(version));
var tabs = 0;
while (!versionFocus.hasPrimaryFocus) {
expect(
tabs++,
lessThan(50),
reason: 'version is not reachable by Tab',
);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
}
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
} else {
await tester.tap(version);
}
await tester.pump();
expect(copied, 'v0.16.0 (432)');
expect(find.text('Version copied'), findsOneWidget);
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
},
);
}
for (final outcome in ['absent', 'error']) {
testWidgets('hides notifications while capability loads and is $outcome', (
tester,