mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
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>
445 lines
15 KiB
Dart
445 lines
15 KiB
Dart
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);
|
||
}
|
||
}
|