Files
buzz/mobile/test/features/profile/settings_profile_header_test.dart
klopez4212 d61fe2071d Use native UIKit scrolling for iOS photo carousels (#8361)
Builds on the mobile polish merged in #8359; this PR now targets `main`.
On iOS, inline photo galleries now use a UIKit collection view for
dragging, deceleration, and snapping. Photo taps retain the existing
full-screen viewer and reply/more actions; gallery scrolling continues
to fade the message avatar. The final photo stops at the full message
gutter rather than the smaller gap between photos. Android keeps the
current Flutter carousel.

Visible cells subscribe to continuing frames from Flutter's existing
authenticated image providers, preserving animated GIF/APNG/WebP media.
Request IDs fence late frames and cancel subscriptions on
reuse/disappearance/disposal. A four-slot bound includes retired
unresolved loads until their transport terminates. Shared media fetches
now use abortable HTTP requests with a 30-second deadline through
response-body completion, so stalled requests release their slots
without leaving live transports behind; native eager precaching is
disabled so it cannot bypass that bound. Frame conversion coalesces to
the newest pending frame. Native cells expose photo labels and positions
to VoiceOver, support RTL, and preserve the selected photo during
resizing.

Validation:
- Mobile formatting and analysis passed.
- Native simulator regression checks passed for geometry, RTL, snapping,
taps, frame replacement, cancellation, and resizing. The native CI lane
now runs the carousel executable.
- Full mobile suite and pre-push gates passed, including two-frame GIF
delivery, delayed-provider bounds/cancellation, real HTTP
stalled-header/body recovery with four requests maximum, disposal, and
unconfigured-push tests.
- Signed iPhone Release build of the frame/subscription implementation
passed. Physical iPhone testing covered the earlier carousel/gutter
revision; the new animation/cancellation changes have local Flutter and
native simulator coverage, not a new physical-device journey.
Preview-only signing overrides preserve its bundle/Keychain group;
production entitlements are unchanged. Local provisioning excludes
production push/App Attest capabilities.
- Full `just ci` was started but stopped after unrelated Rust/desktop
compilation consumed about 5 GB; broad CI is incomplete.

Device build required local Xcode 27 workarounds: iOS 16 deployment
target and a generated swift-secp256k1 cache compatibility edit. Neither
changes repository source.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-15 13:22:12 -04:00

505 lines
16 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/settings_profile_header.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/features/profile/user_status.dart';
import 'package:buzz/features/profile/user_status_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart';
import 'package:buzz/shared/relay/media_auth.dart';
import 'package:buzz/shared/relay/media_image.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('reserves half of the 24dp settings section gap', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
presenceProvider.overrideWith(() => _FakePresenceNotifier('online')),
userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)),
customEmojiListProvider.overrideWithValue(const []),
],
child: const SettingsProfileHeader(),
),
);
await tester.pumpAndSettle();
final header = tester.widget<Padding>(
find.byKey(const ValueKey('settings-profile-header')),
);
expect((header.padding as EdgeInsets).bottom, Grid.twelve);
});
testWidgets('shows the display name directly beneath the avatar', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
presenceProvider.overrideWith(() => _FakePresenceNotifier('online')),
userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)),
customEmojiListProvider.overrideWithValue(const []),
],
child: const SettingsProfileHeader(),
),
);
await tester.pumpAndSettle();
final avatar = find.byKey(const ValueKey('settings-profile-avatar'));
final name = find.text('Test');
expect(name, findsOneWidget);
expect(
tester.getTopLeft(name).dy,
greaterThan(tester.getBottomLeft(avatar).dy),
);
});
testWidgets('shows the poster until the animated avatar is ready', (
tester,
) async {
const posterUrl = 'https://relay.example/media/poster.png';
const animationUrl = 'https://relay.example/media/animation.png';
final profileUrl =
'$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}';
final animationResponse = Completer<http.Response>();
final client = http_testing.MockClient(
(request) => request.url.toString() == animationUrl
? animationResponse.future
: Future.value(http.Response.bytes(_transparentPng, 200)),
);
addTearDown(client.close);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(
() => _FakeProfileNotifier(avatarUrl: profileUrl),
),
presenceProvider.overrideWith(() => _FakePresenceNotifier('online')),
userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)),
customEmojiListProvider.overrideWithValue(const []),
mediaGetAuthServiceProvider.overrideWithValue(
MediaGetAuthService(baseUrl: 'https://relay.example', nsec: null),
),
mediaHttpClientProvider.overrideWithValue(client),
],
child: const SettingsProfileHeader(),
),
);
await tester.pump();
await tester.pump();
expect(
find.byKey(const ValueKey('progressive-animated-avatar-poster')),
findsOneWidget,
);
expect(
tester
.widget<ColoredBox>(
find.byKey(const ValueKey('settings-profile-avatar-background')),
)
.color,
Colors.transparent,
);
expect(
find.byKey(
const ValueKey('progressive-animated-avatar-animation-loading'),
),
findsOneWidget,
);
expect(
tester
.widgetList<MediaImage>(find.byType(MediaImage, skipOffstage: false))
.map((image) => image.url),
containsAll([posterUrl, animationUrl]),
);
animationResponse.complete(http.Response.bytes(_transparentPng, 200));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('progressive-animated-avatar-animation-ready')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('progressive-animated-avatar-poster')),
findsNothing,
);
expect(
tester
.widgetList<MediaImage>(find.byType(MediaImage))
.map((image) => image.url),
[animationUrl],
);
await tester.tap(find.byKey(const ValueKey('settings-profile-avatar')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('progressive-animated-avatar-animation')),
findsNothing,
);
expect(tester.widget<MediaImage>(find.byType(MediaImage)).url, posterUrl);
await tester.tap(find.byKey(const ValueKey('settings-profile-avatar')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('progressive-animated-avatar-animation')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('progressive-animated-avatar-poster')),
findsNothing,
);
});
testWidgets('warms and clears a paused animated-avatar handoff', (
tester,
) async {
const posterUrl = 'https://relay.example/media/poster.png';
const animationUrl = 'https://relay.example/media/animation.png';
final profileUrl =
'$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}';
final posterResponse = Completer<http.Response>();
final animationResponse = Completer<http.Response>();
final client = http_testing.MockClient(
(request) => switch (request.url.toString()) {
posterUrl => posterResponse.future,
animationUrl => animationResponse.future,
_ => Future.value(http.Response.bytes(_transparentPng, 200)),
},
);
addTearDown(client.close);
final container = ProviderContainer(
overrides: [
profileProvider.overrideWith(
() => _FakeProfileNotifier(avatarUrl: profileUrl),
),
presenceProvider.overrideWith(() => _FakePresenceNotifier('online')),
userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)),
customEmojiListProvider.overrideWithValue(const []),
mediaGetAuthServiceProvider.overrideWithValue(
MediaGetAuthService(baseUrl: 'https://relay.example', nsec: null),
),
mediaHttpClientProvider.overrideWithValue(client),
],
);
addTearDown(container.dispose);
container
.read(profileAvatarHandoffProvider.notifier)
.show(
ProfileAvatarHandoff(
avatarUrl: profileUrl,
animation: _transparentPng,
poster: _transparentPng,
),
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(body: SettingsProfileHeader()),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('settings-profile-avatar')));
await tester.pump();
expect(
find.byKey(
ValueKey('settings-profile-paused-handoff-$profileUrl'),
skipOffstage: false,
),
findsOneWidget,
);
expect(container.read(profileAvatarHandoffProvider), isNotNull);
posterResponse.complete(http.Response.bytes(_transparentPng, 200));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pumpAndSettle();
expect(container.read(profileAvatarHandoffProvider), isNull);
// Finish the deliberately paused transport so its deadline is cancelled.
animationResponse.complete(http.Response.bytes(_transparentPng, 200));
await tester.pump();
});
testWidgets('keeps the avatar unbadged while showing the current status', (
tester,
) async {
const missingShortcode = ':very_long_missing_custom_emoji:';
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
presenceProvider.overrideWith(() => _FakePresenceNotifier('online')),
userStatusProvider.overrideWith(
() => _FakeUserStatusNotifier(
const UserStatus(
text: 'Focusing',
emoji: missingShortcode,
updatedAt: 1,
),
),
),
customEmojiListProvider.overrideWithValue(const []),
],
child: const SettingsProfileHeader(),
),
);
await tester.pumpAndSettle();
expect(find.byType(Hero), findsNothing);
expect(find.byType(MaskedAvatarBadge), findsNothing);
expect(find.text('$missingShortcode Focusing'), findsOneWidget);
expect(
tester.getSize(find.byKey(const ValueKey('settings-profile-avatar'))),
const Size(128, 128),
);
});
for (final scenario in [
(emoji: '🎯', text: 'Focusing', resolved: false),
(emoji: ':party_parrot:', text: '', resolved: true),
(emoji: ':party_parrot:', text: 'Celebrating', resolved: true),
(emoji: ':unknown:', text: '', resolved: false),
]) {
testWidgets('preserves status ${scenario.emoji} ${scenario.text}', (
tester,
) async {
final client = http_testing.MockClient(
(_) async => http.Response.bytes(_transparentPng, 200),
);
addTearDown(client.close);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
presenceProvider.overrideWith(
() => _FakePresenceNotifier('online'),
),
userStatusProvider.overrideWith(
() => _FakeUserStatusNotifier(
UserStatus(
emoji: scenario.emoji,
text: scenario.text,
updatedAt: 1,
),
),
),
customEmojiListProvider.overrideWithValue(const [
CustomEmoji(
shortcode: 'party_parrot',
url: 'https://relay.example/parrot.png',
),
]),
mediaHttpClientProvider.overrideWithValue(client),
],
child: const SettingsProfileHeader(),
),
);
await tester.pumpAndSettle();
if (scenario.resolved) {
final image = tester.widget<CustomEmojiImage>(
find.byType(CustomEmojiImage),
);
expect(image.shortcode, 'party_parrot');
expect(image.url, 'https://relay.example/parrot.png');
expect(find.text(':party_parrot:'), findsNothing);
if (scenario.text.isNotEmpty) {
expect(find.textContaining(scenario.text), findsOneWidget);
}
} else {
expect(find.byType(CustomEmojiImage), findsNothing);
expect(
find.text(
[
scenario.emoji,
scenario.text,
].where((s) => s.isNotEmpty).join(' '),
),
findsOneWidget,
);
}
expect(find.byType(MaskedAvatarBadge), findsNothing);
await tester.tap(
scenario.resolved
? find.byType(CustomEmojiImage)
: find.textContaining(scenario.emoji),
);
await tester.pumpAndSettle();
expect(find.text('Set a status'), findsOneWidget);
});
}
testWidgets(
'keeps text-only status visible beside a changeable presence pill',
(tester) async {
final presenceNotifier = _FakePresenceNotifier('away');
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
presenceProvider.overrideWith(() => presenceNotifier),
userStatusProvider.overrideWith(
() => _FakeUserStatusNotifier(
const UserStatus(text: 'Focusing', emoji: '', updatedAt: 1),
),
),
customEmojiListProvider.overrideWithValue(const []),
],
child: const SettingsProfileHeader(),
),
);
await tester.pumpAndSettle();
expect(find.text('Focusing'), findsOneWidget);
await tester.tap(find.text('Focusing'));
await tester.pumpAndSettle();
expect(find.text('Set a status'), findsOneWidget);
expect(
tester.widget<TextField>(find.byType(TextField)).controller?.text,
'Focusing',
);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('settings-presence-label')),
findsOneWidget,
);
expect(find.text('Away'), findsOneWidget);
expect(
tester
.widget<Text>(find.byKey(const ValueKey('settings-presence-label')))
.style
?.fontSize,
filterChipTextStyle.fontSize,
);
expect(
tester
.getSize(find.byKey(const ValueKey('settings-presence-target')))
.height,
48,
);
expect(
tester
.getSize(find.byKey(const ValueKey('settings-presence-pill')))
.height,
greaterThanOrEqualTo(31),
);
final presenceTarget = find.byKey(
const ValueKey('settings-presence-target'),
);
final haptics = <Object?>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'HapticFeedback.vibrate') {
haptics.add(call.arguments);
}
return null;
},
);
addTearDown(() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
);
});
final targetRect = tester.getRect(presenceTarget);
await tester.tapAt(Offset(targetRect.center.dx, targetRect.bottom - 1));
await tester.pump();
expect(haptics, contains('HapticFeedbackType.selectionClick'));
final scale = tester.widget<ScaleTransition>(
find.byKey(const ValueKey('activity-popover-scale')),
);
expect(scale.alignment, Alignment.topCenter);
expect(
find.byKey(const ValueKey('settings-presence-popover')),
findsOneWidget,
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('settings-presence-online')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('settings-presence-away')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('settings-presence-offline')),
findsOneWidget,
);
await tester.tap(find.byKey(const ValueKey('settings-presence-offline')));
await tester.pumpAndSettle();
expect(presenceNotifier.selected, ['offline']);
},
);
}
class _FakeProfileNotifier extends ProfileNotifier {
_FakeProfileNotifier({this.avatarUrl});
final String? avatarUrl;
@override
Future<UserProfile?> build() async =>
UserProfile(pubkey: 'aabb', displayName: 'Test', avatarUrl: avatarUrl);
}
class _FakeUserStatusNotifier extends UserStatusNotifier {
_FakeUserStatusNotifier(this._status);
final UserStatus? _status;
@override
Future<UserStatus?> build() async => _status;
}
class _FakePresenceNotifier extends PresenceNotifier {
_FakePresenceNotifier(this._presence);
final String _presence;
final List<String> selected = [];
@override
Future<String> build() async => _presence;
@override
Future<void> setPresence(String status) async {
selected.add(status);
}
}
final _transparentPng = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAA'
'AAYAAjCB0C8AAAAASUVORK5CYII=',
);