Files
buzz/mobile/test/features/settings/connection_section_test.dart
klopez4212 cb69ad9e4f Adapt iPhone Duo layouts, rail navigation, and native sheets (#8568)
Duo keeps conversations and Settings details open across closed, book
and flat postures. Book mode splits at the fold; flattening restores the
sidebar. Native rail controls, full-width navigation backdrops and
sheet-local safe areas keep content aligned. Ordinary iPhones remain
single-pane in portrait and landscape; tablet and Duo split layouts
remain enabled.

Includes the recovered preview's matching pane widths, Buzz empty-pane
mark, stable Settings chevrons, Bestie pane routing and Duo-only upward
New menu attachment. Current main's Activity redesign, Search content
fade and unread-navigation button are retained. Menu selection checks
sit beside their labels.

Shared mobile changes remain in scope: the native iOS status editor,
Appearance preview layout, avatar crop preservation, onboarding footer
layout, profile/invite sheet geometry and nested conversation
navigation. This PR is not exclusively Duo-gated. Nested navigator
observers preserve recording cancellation and navigation callbacks; crop
resizing preserves the selected image region. iOS 27.1 APIs remain
compile-time and runtime gated.

Validation on the reconciled source: Flutter analysis, Activity/Home
journeys, the full mobile test suite, file-size checks and Swift package
tests. The iPhone single-pane fix was also exercised on an iPhone 18 Pro
simulator before reconciliation. Fresh hosted CI and physical-device
confirmation of the reconciled build are separate and remain pending.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-12 19:00:58 +00:00

445 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:buzz/features/pairing/pairing_provider.dart';
import 'package:buzz/features/settings/settings_page.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/app_list_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('shows a compact copyable identity row', (tester) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform_views,
(_) async => null,
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform_views,
null,
),
);
var hapticCount = 0;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('buzz/haptics'),
(call) async {
expect(call.method, 'success');
hapticCount++;
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('buzz/haptics'),
null,
),
);
MethodCall? clipboardCall;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.setData') clipboardCall = call;
if (call.method == 'HapticFeedback.vibrate') {
fail('Success must use native notification feedback on iOS');
}
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
_usePhoneViewport(tester);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith(
() => _PairingNotifier(Future<bool>.value(true)),
),
savedPrefsProvider.overrideWithValue(prefs),
],
child: SettingsPage(
profileHeader: const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),
);
await tester.pump();
await tester.ensureVisible(find.text('Copy public key (npub)'));
await tester.pumpAndSettle();
final expectedPubkey = nostr.Keys(
'1111111111111111111111111111111111111111111111111111111111111111',
).public;
// Keys('1'×64).public ↔ this npub — the NIP-19 canonical vector for the
// identity row, hardcoded so the codec itself stays under test.
const expectedNpub =
'npub1fu64hh9hes90w2808n8tjc2ajp5yhddjef0ctx4s7zmsgp6cwx4qgy4eg9';
final statusCard = find.byKey(const ValueKey('status-identity-options'));
expect(
find.descendant(of: statusCard, matching: find.text('Set status')),
findsOneWidget,
);
expect(
find.descendant(
of: statusCard,
matching: find.text('Copy public key (npub)'),
),
findsOneWidget,
);
expect(
tester.getTopLeft(find.text('Copy public key (npub)')).dy,
lessThan(tester.getTopLeft(find.text('Display name')).dy),
);
expect(find.text('Connection'), findsNothing);
final sendCard = find.ancestor(
of: find.text('Send identity to desktop'),
matching: find.byType(AppListCard),
);
expect(
find.descendant(
of: sendCard,
matching: find.text('Copy public key (npub)'),
),
findsNothing,
);
expect(find.text('Connected to'), findsNothing);
expect(find.text('https://relay.test'), findsNothing);
// Neither the raw hex key nor the full npub is rendered visually — the
// full npub is exposed through a11y and the clipboard only.
expect(find.text(expectedPubkey), findsNothing);
expect(find.text(expectedNpub), findsNothing);
// The identity row's a11y value carries the full npub (not raw hex).
expect(
find.ancestor(
of: find.text('Copy public key (npub)'),
matching: find.byWidgetPredicate(
(widget) =>
widget is Semantics && widget.properties.value == expectedNpub,
),
),
findsOneWidget,
);
final copy = tester.getRect(find.byIcon(BuzzIcons.copy));
final chevron = tester.getRect(find.byIcon(BuzzIcons.chevronRight).first);
expect(copy.center.dx, closeTo(chevron.center.dx, 0.5));
await tester.tap(find.text('Copy public key (npub)'));
await tester.pumpAndSettle();
expect(hapticCount, 1);
expect(clipboardCall?.method, 'Clipboard.setData');
expect(clipboardCall?.arguments, {'text': expectedNpub});
expect(find.text('Public key copied'), findsOneWidget);
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
testWidgets('an out-of-range identity shows as unavailable', (tester) async {
_usePhoneViewport(tester);
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_OutOfRangeRelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith(
() => _PairingNotifier(Future<bool>.value(true)),
),
savedPrefsProvider.overrideWithValue(prefs),
],
child: SettingsPage(
profileHeader: const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),
);
await tester.pump();
await tester.ensureVisible(find.text('Copy public key (npub)'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
find.ancestor(
of: find.text('Copy public key (npub)'),
matching: find.byWidgetPredicate(
(widget) =>
widget is Semantics &&
widget.properties.value == 'Identity unavailable',
),
),
findsOneWidget,
);
});
testWidgets('waits for a resumed frame before navigating after auth', (
tester,
) async {
final authorization = Completer<bool>();
final pairing = _PairingNotifier(authorization.future);
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
_usePhoneViewport(tester);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith(() => pairing),
savedPrefsProvider.overrideWithValue(prefs),
],
child: SettingsPage(
profileHeader: const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')),
),
),
);
await tester.pump();
await tester.tap(find.text('Send identity to desktop'));
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
authorization.complete(true);
await tester.pump();
expect(find.text('Identity recovery'), findsNothing);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Identity recovery'), findsOneWidget);
});
testWidgets('resume timeout keeps identity recovery closed', (tester) async {
final pairing = _PairingNotifier(Future<bool>.value(true));
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
_usePhoneViewport(tester);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith(() => pairing),
savedPrefsProvider.overrideWithValue(prefs),
],
child: SettingsPage(
profileHeader: const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')),
),
),
);
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await tester.tap(find.text('Send identity to desktop'));
await tester.pump();
await tester.pump(const Duration(seconds: 5));
await tester.pump();
expect(pairing.resetCalls, 1);
expect(find.text('Identity recovery'), findsNothing);
expect(find.text('Couldn’t resume Buzz. Try again.'), findsOneWidget);
});
testWidgets('clears authorization when disposed during authentication', (
tester,
) async {
final authorization = Completer<bool>();
final pairing = _PairingNotifier(authorization.future);
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
_usePhoneViewport(tester);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith(() => pairing),
savedPrefsProvider.overrideWithValue(prefs),
],
child: SettingsPage(
profileHeader: const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')),
),
),
);
await tester.pump();
final settingsContext = tester.element(
find.text('Send identity to desktop'),
);
await tester.tap(find.text('Send identity to desktop'));
await tester.pump();
unawaited(
Navigator.of(settingsContext).pushReplacement(
MaterialPageRoute<void>(builder: (_) => const SizedBox.shrink()),
),
);
await tester.pumpAndSettle();
authorization.complete(true);
await tester.pump();
expect(pairing.resetCalls, 1);
expect(find.text('Identity recovery'), findsNothing);
});
testWidgets('clears authorization when disposed during resume wait', (
tester,
) async {
final pairing = _PairingNotifier(Future<bool>.value(true));
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
_usePhoneViewport(tester);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith(() => pairing),
savedPrefsProvider.overrideWithValue(prefs),
],
child: SettingsPage(
profileHeader: const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')),
),
),
);
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
final settingsContext = tester.element(
find.text('Send identity to desktop'),
);
await tester.tap(find.text('Send identity to desktop'));
await tester.pump();
unawaited(
Navigator.of(settingsContext).pushReplacement(
MaterialPageRoute<void>(builder: (_) => const SizedBox.shrink()),
),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 5));
await tester.pump();
expect(pairing.resetCalls, 1);
expect(find.text('Identity recovery'), findsNothing);
});
testWidgets('denied authentication does not open identity recovery', (
tester,
) async {
final pairing = _PairingNotifier(Future<bool>.value(false));
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
_usePhoneViewport(tester);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith(() => pairing),
savedPrefsProvider.overrideWithValue(prefs),
],
child: SettingsPage(
profileHeader: const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')),
),
),
);
await tester.pump();
await tester.tap(find.text('Send identity to desktop'));
await tester.pumpAndSettle();
expect(pairing.authorizationCalls, 1);
expect(find.text('Identity recovery'), findsNothing);
});
}
class _AuthNotifier extends AuthNotifier {
@override
Future<AuthState> build() async => AuthState(
status: AuthStatus.authenticated,
community: Community(
id: 'community',
name: 'Test',
relayUrl: 'https://relay.test',
nsec: _RelayConfigNotifier.nsec,
addedAt: DateTime.utc(2026),
),
);
}
class _RelayConfigNotifier extends RelayConfigNotifier {
static final nsec = nostr.Keys(
'1111111111111111111111111111111111111111111111111111111111111111',
).nsec;
@override
RelayConfig build() => RelayConfig(baseUrl: 'https://relay.test', nsec: nsec);
}
class _OutOfRangeRelayConfigNotifier extends RelayConfigNotifier {
// Valid bech32 and 64 hex chars, but not below the secp256k1 order.
static final nsec = nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: 'f' * 64,
);
@override
RelayConfig build() => RelayConfig(baseUrl: 'https://relay.test', nsec: nsec);
}
class _PairingNotifier extends PairingNotifier {
_PairingNotifier(this.authorization);
final Future<bool> authorization;
int authorizationCalls = 0;
int resetCalls = 0;
@override
PairingState build() => const PairingState();
@override
Future<bool> authorizeIdentityExport({required Community community}) {
authorizationCalls++;
return authorization;
}
@override
void reset() {
resetCalls++;
}
}
void _usePhoneViewport(WidgetTester tester) {
if (tester.view.physicalSize.width / tester.view.devicePixelRatio >= 640) {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(390, 1200);
addTearDown(tester.view.reset);
}
}