From 38a5d9a1ffe27c8f403dd0b4d1c8aa408a474d40 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 3 Nov 2026 18:39:44 -0800 Subject: [PATCH] mobile: tap the settings version footer to copy it (#9021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Tom Brow Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- .../lib/features/settings/settings_page.dart | 40 ++++++++-- .../features/settings/settings_page_test.dart | 73 +++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index d9ab3fe073..8fd32af987 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -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, + ), + ), + ), + ), ), ), ), diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index fe57d9be35..6f741d25ad 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -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,