Files
buzz/mobile/test/features/profile/profile_edit_page_test.dart
klopez4212 ed11c8d8bf Refresh mobile utility surfaces and theme picker (#6944)
## Summary

- invert mobile utility surfaces so pages and sheets use the softer page
background with raised containers
- rebuild the community theme picker around swipeable Home/Chat
previews, accent and appearance controls, and native iOS glass
interactions
- align shared sheet/profile spacing and radii, and move Theme into the
Community settings card

### Related issue

None found.

### Testing

- `just mobile-check`
- `just mobile-test` (1,871 tests)
- signed iOS Release build installed on a physical iPhone

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
2026-08-28 18:56:42 +01:00

1093 lines
36 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:buzz/features/profile/profile_edit_page.dart';
import 'package:buzz/features/profile/profile_avatar_draft.dart';
import 'package:buzz/features/profile/profile_avatar_crop_page.dart';
import 'package:buzz/features/profile/avatar_background_grid.dart';
import 'package:buzz/features/profile/avatar_editor_option_button.dart';
import 'package:buzz/features/profile/animated_avatar_capture.dart';
import 'package:buzz/features/profile/emoji_avatar_tile.dart';
import 'package:buzz/features/profile/image_avatar_capture.dart';
import 'package:buzz/shared/widgets/immediate_page_route.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/shared/emoji/emoji_avatar.dart';
import 'package:buzz/shared/emoji/emoji_data.dart';
import 'package:buzz/shared/emoji/emoji_data_provider.dart';
import 'package:buzz/shared/emoji/native_emoji_glyph.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/ios_native_segmented_control.dart';
import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart';
import 'package:buzz/shared/widgets/playing_avatar_image.dart';
import 'package:buzz/shared/widgets/progressive_animated_avatar.dart';
import 'package:flutter/foundation.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:image/image.dart' as image;
import 'package:image_picker/image_picker.dart';
import '../../helpers/widget_helpers.dart';
part 'profile_edit_page_test/motion_and_accessibility_tests.dart';
part 'profile_edit_page_test/image_selection_tests.dart';
const _editorControlBottomForTest = Grid.xl + Grid.xxs;
void main() {
testWidgets('keeps crop Save disabled while dimensions decode', (
tester,
) async {
final bytes = Uint8List.fromList(
image.encodePng(image.Image(width: 20, height: 10)),
);
await tester.pumpWidget(
MaterialApp(
home: ProfileAvatarCropPage(
imageBytes: Future<Uint8List?>.value(bytes),
),
),
);
await tester.pump();
await tester.pump();
final saveButton = tester.widget<TextButton>(
find.descendant(
of: find.byKey(const ValueKey('avatar-crop-use-photo')),
matching: find.byType(TextButton),
),
);
expect(saveButton.onPressed, isNull);
});
testWidgets('can open directly into the photo editor from Settings', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)],
child: const ProfileEditPage(startInPhotoEditor: true),
),
);
await tester.pump();
expect(find.text('Edit Photo'), findsOneWidget);
expect(
find.byKey(const ValueKey('profile-avatar-editor-page')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('profile-information-page')),
findsNothing,
);
final entrance = find.byKey(
const ValueKey('avatar-editor-entrance-transform'),
);
final initialOffset = tester
.widget<Transform>(entrance)
.transform
.getTranslation()
.y;
final screenHeight =
tester.view.physicalSize.height / tester.view.devicePixelRatio;
final appBarHeight = frostedAppBarHeight(tester.element(entrance));
final expectedOffset = appBarHeight + 96 - screenHeight / 2;
expect(initialOffset, closeTo(expectedOffset, 0.01));
await tester.pump(const Duration(milliseconds: 100));
final midpointOffset = tester
.widget<Transform>(entrance)
.transform
.getTranslation()
.y;
expect(midpointOffset, greaterThan(initialOffset));
expect(midpointOffset, lessThan(0));
await tester.pumpAndSettle();
expect(
tester.widget<Transform>(entrance).transform.getTranslation().y,
closeTo(0, 0.01),
);
expect(
tester
.getCenter(find.byKey(const ValueKey('avatar-editor-fixed-preview')))
.dy,
closeTo(screenHeight / 2, 0.01),
);
});
testWidgets('return motion eases out into the Settings avatar position', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)],
child: MaterialApp(
theme: AppTheme.light(),
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () => Navigator.of(context).push(
immediatePageRoute<void>(
builder: (_) =>
const ProfileEditPage(startInPhotoEditor: true),
),
),
child: const Text('Open editor'),
),
),
),
),
),
);
await tester.tap(find.text('Open editor'));
await tester.pumpAndSettle();
final entrance = find.byKey(
const ValueKey('avatar-editor-entrance-transform'),
);
expect(
tester.widget<Transform>(entrance).transform.getTranslation().y,
closeTo(0, 0.01),
);
await tester.tap(find.byKey(const ValueKey('avatar-editor-back')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
final quarterOffset = tester
.widget<Transform>(entrance)
.transform
.getTranslation()
.y;
final screenHeight =
tester.view.physicalSize.height / tester.view.devicePixelRatio;
final appBarHeight = frostedAppBarHeight(tester.element(entrance));
final collapsedOffset = appBarHeight + 96 - screenHeight / 2;
expect(quarterOffset, lessThan(collapsedOffset / 2));
expect(quarterOffset, greaterThan(collapsedOffset));
await tester.pumpAndSettle();
expect(find.text('Open editor'), findsOneWidget);
expect(
find.byKey(const ValueKey('profile-avatar-editor-page')),
findsNothing,
);
});
testWidgets('uses the native segmented control for photo modes on iOS', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)],
child: const ProfileEditPage(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Edit Photo'));
await tester.pump();
expect(find.byType(IosNativeSegmentedControl), findsOneWidget);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('uses the native profile text form on iOS', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
const channel = MethodChannel('buzz/profile_text_editor');
MethodCall? receivedCall;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
receivedCall = call;
return 'Alice Native';
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null),
);
final notifier = _FakeProfileNotifier();
await tester.pumpWidget(
ProviderScope(
overrides: [profileProvider.overrideWith(() => notifier)],
child: MaterialApp(
theme: AppTheme.dark(),
home: const Scaffold(body: ProfileEditPage()),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('profile-display-name-row')));
await tester.pumpAndSettle();
expect(receivedCall?.method, 'present');
expect(receivedCall?.arguments, {
'title': 'Display name',
'initialValue': 'Alice',
'placeholder': 'Display name',
'multiline': false,
'brightness': 'dark',
'pageBackgroundArgb': AppTheme.dark().colorScheme.surfaceContainerHighest
.toARGB32(),
'containerBackgroundArgb': AppTheme.dark().colorScheme.surface.toARGB32(),
'containerCornerRadius': Radii.container,
'allowUnchangedSubmission': false,
});
expect(notifier.savedDisplayNames, ['Alice Native']);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('shows profile fields and saves each one from a sheet', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final notifier = _FakeProfileNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [profileProvider.overrideWith(() => notifier)],
child: const ProfileEditPage(),
),
);
await tester.pumpAndSettle();
expect(find.text('Edit Photo'), findsOneWidget);
expect(find.text('Display name'), findsOneWidget);
expect(find.text('Profile description'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Building Buzz'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('profile-display-name-row')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('profile-field-input')),
'Alice L',
);
await tester.pump();
await tester.tap(find.byKey(const ValueKey('profile-field-save')));
await tester.pumpAndSettle();
expect(notifier.savedDisplayNames, ['Alice L']);
expect(find.text('Alice L'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('profile-description-row')));
await tester.pumpAndSettle();
final field = tester.widget<TextField>(
find.byKey(const ValueKey('profile-field-input')),
);
expect(field.minLines, 4);
await tester.enterText(
find.byKey(const ValueKey('profile-field-input')),
'Making collaboration feel effortless.',
);
await tester.pump();
await tester.tap(find.byKey(const ValueKey('profile-field-save')));
await tester.pumpAndSettle();
expect(notifier.savedDescriptions, [
'Making collaboration feel effortless.',
]);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('uses an icon-free grouped card with inset dividers', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)],
child: const ProfileEditPage(),
),
);
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byKey(const ValueKey('profile-info-card')),
matching: find.byType(Icon),
),
findsNWidgets(2),
);
expect(
find.descendant(
of: find.byKey(const ValueKey('profile-info-card')),
matching: find.byType(Divider),
),
findsOneWidget,
);
});
testWidgets('uploads a selected photo and updates the profile', (
tester,
) async {
final notifier = _FakeProfileNotifier();
final uploadService = _FakeMediaUploadService();
addTearDown(uploadService.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(() => notifier),
mediaUploadServiceProvider.overrideWithValue(uploadService),
],
child: const ProfileEditPage(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Edit Photo'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('avatar-mode-control')), findsOneWidget);
expect(find.text('Image'), findsOneWidget);
expect(find.text('Camera'), findsOneWidget);
expect(find.text('Photo Library'), findsOneWidget);
expect(find.text('Emoji'), findsOneWidget);
expect(find.text('Animated'), findsOneWidget);
expect(find.text('Photo'), findsNothing);
final modeControl = find.byKey(const ValueKey('avatar-mode-control'));
final modeIndicator = find.byKey(const ValueKey('avatar-mode-indicator'));
final controlRect = tester.getRect(modeControl);
final indicatorRect = tester.getRect(modeIndicator);
expect(indicatorRect.left - controlRect.left, Grid.quarter);
expect(indicatorRect.top - controlRect.top, Grid.quarter);
expect(controlRect.bottom - indicatorRect.bottom, Grid.quarter);
expect(
controlRect.width - Grid.quarter * 2,
closeTo(indicatorRect.width * 3, 0.01),
);
final indicatorDecoration =
tester.widget<DecoratedBox>(modeIndicator).decoration as BoxDecoration;
expect(indicatorDecoration.boxShadow, isNull);
final preview = find.descendant(
of: find.byKey(const ValueKey('avatar-editor-content')),
matching: find.byType(AvatarImage),
);
expect(
tester.getTopLeft(modeControl).dy,
lessThan(tester.getTopLeft(preview).dy),
);
expect(tester.widget<AvatarImage>(preview).radius, 110);
await tester.tap(find.text('Photo Library'));
await _waitForAvatarCropToLoad(tester);
expect(find.text('Position Photo'), findsOneWidget);
final cancelButton = find.ancestor(
of: find.text('Cancel'),
matching: find.byType(TextButton),
);
final saveButton = find.byKey(const ValueKey('avatar-crop-use-photo'));
expect(tester.getRect(cancelButton).left, Grid.gutter);
expect(
tester.getSize(find.text('Cancel')).width,
lessThan(tester.getSize(cancelButton).width),
);
expect(
tester.getSize(find.byType(Scaffold).last).width -
tester.getRect(saveButton).right,
Grid.gutter,
);
final cropViewerFinder = find.byKey(const ValueKey('avatar-crop-viewer'));
final cropViewer = tester.widget<InteractiveViewer>(cropViewerFinder);
final cropViewport = tester.getSize(cropViewerFinder);
final cropDiameter = math.min(cropViewport.width, cropViewport.height);
final expectedX = (cropViewport.width - cropDiameter) / 2;
final expectedY = (cropViewport.height - cropDiameter) / 2;
await tester.drag(cropViewerFinder, const Offset(1000, 1000));
await tester.pump();
expect(
cropViewer.transformationController!.value.storage[12],
closeTo(expectedX, 0.01),
);
expect(
cropViewer.transformationController!.value.storage[13],
closeTo(expectedY, 0.01),
);
await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo')));
await _waitForAvatarCropToClose(tester);
expect(notifier.savedAvatarUrls, isEmpty);
expect(uploadService.uploadCount, 0);
await tester.tap(find.byKey(const ValueKey('avatar-save')));
await tester.pumpAndSettle();
expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']);
expect(uploadService.uploadCount, 1);
});
testWidgets('keeps a failed avatar draft visible and retries publish', (
tester,
) async {
final notifier = _FakeProfileNotifier(failedAvatarSaves: 1);
final uploadService = _FakeMediaUploadService();
addTearDown(uploadService.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(() => notifier),
mediaUploadServiceProvider.overrideWithValue(uploadService),
],
child: const ProfileEditPage(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Edit Photo'));
await tester.pumpAndSettle();
await tester.tap(find.text('Photo Library'));
await _waitForAvatarCropToLoad(tester);
await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo')));
await _waitForAvatarCropToClose(tester);
await tester.tap(find.byKey(const ValueKey('avatar-save')));
await tester.pumpAndSettle();
expect(
find.text("We couldn't save your profile photo. Try again."),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('profile-avatar-editor-page')),
findsOneWidget,
);
expect(uploadService.uploadCount, 1);
await tester.tap(find.byKey(const ValueKey('avatar-save')));
await tester.pumpAndSettle();
expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']);
expect(uploadService.uploadCount, 1);
});
testWidgets('centers every preview while controls fill the page gutters', (
tester,
) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(800, 900);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)],
child: const ProfileEditPage(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Edit Photo'));
await tester.pumpAndSettle();
final screenSize = tester.view.physicalSize / tester.view.devicePixelRatio;
final expectedContentWidth = screenSize.width - Grid.gutter * 2;
expect(
tester
.getCenter(find.byKey(const ValueKey('avatar-editor-fixed-preview')))
.dy,
closeTo(screenSize.height / 2, 0.01),
);
expect(
tester.getSize(find.byKey(const ValueKey(0))).width,
closeTo(expectedContentWidth, 0.01),
);
expect(find.byKey(const ValueKey('image-source-camera')), findsOneWidget);
expect(find.byKey(const ValueKey('image-source-library')), findsOneWidget);
final cameraSurface = find.descendant(
of: find.byKey(const ValueKey('image-source-camera')),
matching: find.byType(AnimatedContainer),
);
final librarySurface = find.descendant(
of: find.byKey(const ValueKey('image-source-library')),
matching: find.byType(AnimatedContainer),
);
final imageControlGap =
tester.getRect(librarySurface).left -
tester.getRect(cameraSurface).right;
expect(imageControlGap, greaterThan(Grid.twelve));
expect(
screenSize.height -
tester
.getRect(find.byKey(const ValueKey('image-source-camera')))
.bottom,
_editorControlBottomForTest,
);
await tester.tap(find.text('Emoji'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
expect(
tester.getCenter(find.byKey(const ValueKey('emoji-avatar-preview'))).dy,
closeTo(screenSize.height / 2 - 140, 0.01),
);
expect(
tester
.getSize(find.byKey(const ValueKey('emoji-avatar-picker-content')))
.width,
closeTo(expectedContentWidth, 0.01),
);
expect(
find.byKey(const ValueKey('emoji-editor-background')),
findsOneWidget,
);
expect(find.byKey(const ValueKey('emoji-editor-emoji')), findsOneWidget);
expect(
find.descendant(
of: find.byKey(const ValueKey('emoji-avatar-picker-content')),
matching: find.byType(AnimatedSwitcher),
),
findsNothing,
);
final backgroundSurface = find.descendant(
of: find.byKey(const ValueKey('emoji-editor-background')),
matching: find.byType(AnimatedContainer),
);
final emojiSurface = find.descendant(
of: find.byKey(const ValueKey('emoji-editor-emoji')),
matching: find.byType(AnimatedContainer),
);
expect(
tester.getRect(emojiSurface).left -
tester.getRect(backgroundSurface).right,
closeTo(imageControlGap, 0.01),
);
expect(
screenSize.height -
tester
.getRect(find.byKey(const ValueKey('emoji-editor-background')))
.bottom,
_editorControlBottomForTest,
);
final expandedPickerHeight = tester
.getSize(find.byKey(const ValueKey('emoji-avatar-picker-content')))
.height;
await tester.tap(find.byKey(const ValueKey('emoji-editor-background')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
expect(
tester.getCenter(find.byKey(const ValueKey('emoji-avatar-preview'))).dy,
closeTo(screenSize.height / 2 - 140, 0.01),
);
expect(
tester
.getSize(find.byKey(const ValueKey('emoji-avatar-picker-content')))
.height,
expandedPickerHeight,
);
expect(
tester.getCenter(find.byKey(const ValueKey('emoji-background-editor'))),
tester.getCenter(
find.byKey(const ValueKey('emoji-background-editor-alignment')),
),
);
await tester.tap(find.text('Animated'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
expect(
tester
.getCenter(
find.byKey(const ValueKey('animated-avatar-capture-preview')),
)
.dy,
closeTo(screenSize.height / 2, 0.01),
);
expect(
tester.getSize(find.byKey(const ValueKey(2))).width,
closeTo(expectedContentWidth, 0.01),
);
final recordButton = find.byKey(
const ValueKey('animated-avatar-record-morph'),
);
expect(tester.getSize(recordButton).height, 64);
expect(
tester.getSize(recordButton).width,
closeTo(expectedContentWidth, 0.01),
);
final recordMaterial = tester.widget<Material>(
find.descendant(of: recordButton, matching: find.byType(Material)).first,
);
expect(
recordMaterial.borderRadius,
const BorderRadius.all(Radius.circular(Radii.full)),
);
expect(
screenSize.height - tester.getRect(recordButton).bottom,
_editorControlBottomForTest,
);
expect(
find.byKey(const ValueKey('avatar-mode-transition-transform')),
findsNothing,
);
});
testWidgets('waits for the photo picker before opening Position Photo', (
tester,
) async {
final uploadService = _FakeMediaUploadService(delayGallery: true);
addTearDown(uploadService.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
mediaUploadServiceProvider.overrideWithValue(uploadService),
],
child: const ProfileEditPage(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Edit Photo'));
await tester.pumpAndSettle();
await tester.tap(find.text('Photo Library'));
await tester.pump();
expect(find.text('Position Photo'), findsNothing);
expect(
find.byKey(const ValueKey('avatar-image-loading-overlay')),
findsOneWidget,
);
uploadService.completeGallerySelection();
await _waitForAvatarCropToLoad(tester);
expect(find.text('Position Photo'), findsOneWidget);
});
testWidgets('captures a camera photo and updates the profile', (
tester,
) async {
final notifier = _FakeProfileNotifier();
final uploadService = _FakeMediaUploadService();
addTearDown(uploadService.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(() => notifier),
mediaUploadServiceProvider.overrideWithValue(uploadService),
],
child: ProfileEditPage(
imageAvatarCaptureBuilder:
({required height, required onAccepted, required onClosed}) =>
_FakeImageAvatarCapture(
onAccepted: onAccepted,
onClosed: onClosed,
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Edit Photo'));
await tester.pumpAndSettle();
await tester.tap(find.text('Camera'));
await tester.pump();
expect(find.byKey(const ValueKey('fake-image-camera')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('fake-image-camera-accept')));
await tester.pumpAndSettle();
expect(notifier.savedAvatarUrls, isEmpty);
await tester.tap(find.byKey(const ValueKey('avatar-save')));
await tester.pumpAndSettle();
expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']);
});
testWidgets('keeps the local animation visible after profile Save', (
tester,
) async {
final notifier = _FakeProfileNotifier(updatesProfileState: true);
final uploadService = _FakeMediaUploadService();
addTearDown(uploadService.dispose);
final frame = Uint8List.fromList(
image.encodePng(
image.Image(width: 8, height: 8)..setPixelRgba(4, 4, 255, 0, 0, 255),
),
);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(() => notifier),
mediaUploadServiceProvider.overrideWithValue(uploadService),
],
child: ProfileEditPage(
animatedAvatarCaptureBuilder:
({required height, required onPrepareChanged}) {
WidgetsBinding.instance.addPostFrameCallback((_) {
onPrepareChanged(
() async => ProfileAnimatedAvatarDraft(
animation: frame,
poster: frame,
),
);
});
return const SizedBox(
key: ValueKey('fake-animated-avatar-review'),
);
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Edit Photo'));
await tester.pumpAndSettle();
await tester.tap(find.text('Animated'));
await tester.pump();
await tester.pump();
await tester.tap(find.byKey(const ValueKey('avatar-save')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('progressive-animated-avatar-local-handoff')),
findsOneWidget,
);
expect(notifier.savedAvatarUrls.single, contains('#buzz-anim='));
});
testWidgets('saves a desktop-compatible emoji avatar', (tester) async {
final notifier = _FakeProfileNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [profileProvider.overrideWith(() => notifier)],
child: const ProfileEditPage(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Edit Photo'));
await tester.pumpAndSettle();
await tester.tap(find.text('Emoji'));
await tester.pump();
expect(find.byKey(const ValueKey('emoji-avatar-search')), findsOneWidget);
expect(
find.byKey(const ValueKey('emoji-avatar-skin-tone')),
findsOneWidget,
);
expect(
tester.getSize(find.byKey(const ValueKey('emoji-avatar-preview'))),
const Size.square(220),
);
await tester.enterText(
find.byKey(const ValueKey('emoji-avatar-search')),
'rocket',
);
await tester.pump(const Duration(milliseconds: 300));
final focusedSearch = tester.widget<TextField>(
find.byKey(const ValueKey('emoji-avatar-search')),
);
final focusedBorder =
focusedSearch.decoration?.focusedBorder! as OutlineInputBorder;
expect(
focusedBorder.borderRadius,
const BorderRadius.all(Radius.circular(Radii.full)),
);
expect(
tester
.widget<TextField>(find.byKey(const ValueKey('emoji-avatar-search')))
.controller
?.text,
'rocket',
);
await tester.tap(find.byKey(const ValueKey('emoji-avatar-skin-tone')));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Dark'), findsOneWidget);
await tester.tapAt(const Offset(8, 8));
await tester.pump(const Duration(milliseconds: 300));
await tester.enterText(
find.byKey(const ValueKey('emoji-avatar-search')),
'',
);
await tester.pump(const Duration(milliseconds: 300));
final previewFinder = find.byKey(const ValueKey('emoji-avatar-preview'));
final previewBefore = tester.widget<AnimatedContainer>(previewFinder);
final decorationBefore = previewBefore.decoration! as BoxDecoration;
expect(decorationBefore.color, isNot(Colors.white));
final glyph = tester.widget<NativeEmojiGlyph>(
find.descendant(
of: previewFinder,
matching: find.byType(NativeEmojiGlyph),
),
);
expect(glyph.size, closeTo(220 * 258 / 512, 0.01));
final nextColorIndex = emojiAvatarColors.indexWhere(
(value) => Color(value) != decorationBefore.color,
);
final nextColor = find.byKey(
ValueKey('emoji-avatar-color-$nextColorIndex'),
);
await tester.tap(find.byKey(const ValueKey('emoji-editor-background')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
await tester.tap(nextColor);
await tester.pump();
final previewAfter = tester.widget<AnimatedContainer>(previewFinder);
expect(previewAfter.decoration, isNot(previewBefore.decoration));
await tester.tap(find.byKey(const ValueKey('avatar-save')));
await tester.pumpAndSettle();
final avatarUrl = notifier.savedAvatarUrls.single;
expect(avatarUrl, startsWith('data:image/svg+xml,'));
expect(Uri.decodeComponent(avatarUrl), contains('😊'));
expect(
find.byKey(const ValueKey('profile-information-page')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('profile-avatar-editor-page')),
findsNothing,
);
});
testWidgets('saves the displayed default emoji without another selection', (
tester,
) async {
final notifier = _FakeProfileNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [profileProvider.overrideWith(() => notifier)],
child: const ProfileEditPage(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Edit Photo'));
await tester.pumpAndSettle();
await tester.tap(find.text('Emoji'));
await tester.pump(const Duration(milliseconds: 250));
await tester.tap(find.byKey(const ValueKey('avatar-save')));
await tester.pumpAndSettle();
expect(notifier.savedAvatarUrls, hasLength(1));
expect(notifier.savedAvatarUrls.single, startsWith('data:image/svg+xml,'));
expect(
Uri.decodeComponent(notifier.savedAvatarUrls.single),
contains('😊'),
);
});
testWidgets('keeps emoji drafts scoped to the emoji mode', (tester) async {
final notifier = _FakeProfileNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [profileProvider.overrideWith(() => notifier)],
child: const ProfileEditPage(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Edit Photo'));
await tester.pumpAndSettle();
await tester.tap(find.text('Emoji'));
await tester.pump(const Duration(milliseconds: 500));
await tester.tap(find.byKey(const ValueKey('emoji-editor-background')));
await tester.pump(const Duration(milliseconds: 200));
await tester.tap(find.byKey(const ValueKey('emoji-avatar-color-1')));
await tester.pump();
await tester.tap(find.text('Image'));
await tester.pump(const Duration(milliseconds: 500));
await tester.tap(find.byKey(const ValueKey('avatar-save')));
await tester.pump();
expect(notifier.savedAvatarUrls, isEmpty);
expect(
find.byKey(const ValueKey('profile-avatar-editor-page')),
findsOneWidget,
);
});
runProfileEditMotionAndAccessibilityTests();
runProfileEditImageSelectionTests();
}
Future<void> _waitForAvatarCropToClose(WidgetTester tester) async {
final cropPage = find.byKey(const ValueKey('avatar-crop-viewer'));
for (var attempt = 0; attempt < 100; attempt += 1) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 25)),
);
await tester.pump(const Duration(milliseconds: 25));
if (cropPage.evaluate().isEmpty) {
await tester.pump(const Duration(milliseconds: 250));
return;
}
}
fail('Avatar crop did not complete within 5 seconds.');
}
Future<void> _waitForAvatarCropToLoad(WidgetTester tester) async {
final cropViewer = find.byKey(const ValueKey('avatar-crop-viewer'));
for (var attempt = 0; attempt < 100; attempt += 1) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 25)),
);
await tester.pump(const Duration(milliseconds: 25));
if (cropViewer.evaluate().isNotEmpty) {
await tester.pumpAndSettle();
return;
}
}
fail('Avatar crop did not load within 5 seconds.');
}
class _FakeProfileNotifier extends ProfileNotifier {
_FakeProfileNotifier({
this.profile = const UserProfile(
pubkey: 'aabb',
displayName: 'Alice',
about: 'Building Buzz',
),
this.failedAvatarSaves = 0,
this.updatesProfileState = false,
});
final UserProfile profile;
int failedAvatarSaves;
final bool updatesProfileState;
final savedDisplayNames = <String>[];
final savedDescriptions = <String>[];
final savedAvatarUrls = <String>[];
@override
Future<UserProfile?> build() async => profile;
@override
Future<void> updateDisplayName(String displayName) async {
savedDisplayNames.add(displayName);
final current = state.requireValue!;
state = AsyncData(
UserProfile(
pubkey: current.pubkey,
displayName: displayName,
avatarUrl: current.avatarUrl,
about: current.about,
nip05Handle: current.nip05Handle,
),
);
}
@override
Future<void> updateAbout(String about) async {
savedDescriptions.add(about);
final current = state.requireValue!;
state = AsyncData(
UserProfile(
pubkey: current.pubkey,
displayName: current.displayName,
avatarUrl: current.avatarUrl,
about: about,
nip05Handle: current.nip05Handle,
),
);
}
@override
Future<void> updateAvatarUrl(String avatarUrl) async {
if (failedAvatarSaves > 0) {
failedAvatarSaves--;
throw Exception('profile publish failed');
}
savedAvatarUrls.add(avatarUrl);
if (updatesProfileState) {
final current = state.requireValue!;
state = AsyncData(
UserProfile(
pubkey: current.pubkey,
displayName: current.displayName,
avatarUrl: avatarUrl,
about: current.about,
nip05Handle: current.nip05Handle,
),
);
}
}
}
class _FakeMediaUploadService extends MediaUploadService {
_FakeMediaUploadService({
this.delayGallery = false,
this.failImagePreparation = false,
}) : super(
baseUrl: 'https://relay.example',
nsec: null,
pickGalleryImage: () async => null,
pickGalleryVideo: () async => null,
);
var _camera = false;
final bool delayGallery;
final bool failImagePreparation;
final _gallerySelection = Completer<XFile?>();
int uploadCount = 0;
XFile _image() => XFile.fromData(
image.encodePng(image.Image(width: 8, height: 8)),
mimeType: 'image/png',
name: 'avatar.png',
);
@override
Future<XFile?> pickGalleryImage() async {
_camera = false;
if (delayGallery) return _gallerySelection.future;
return _image();
}
void completeGallerySelection() => _gallerySelection.complete(_image());
@override
Future<XFile?> captureImage() async {
_camera = true;
return _image();
}
@override
Future<Uint8List> prepareImageBytes(XFile image) async {
if (failImagePreparation) throw Exception('image preparation failed');
return image.readAsBytes();
}
@override
Future<BlobDescriptor> uploadBytes(
Uint8List bytes, {
required String mimeType,
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
uploadCount++;
return BlobDescriptor(
url: _camera
? 'https://relay.example/camera.png'
: 'https://relay.example/profile.png',
sha256: _camera ? 'camera-hash' : 'hash',
size: bytes.length,
type: mimeType,
uploaded: 1,
);
}
}
class _FakeImageAvatarCapture extends StatelessWidget {
const _FakeImageAvatarCapture({
required this.onAccepted,
required this.onClosed,
});
final ValueChanged<Uint8List> onAccepted;
final VoidCallback onClosed;
@override
Widget build(BuildContext context) => Row(
key: const ValueKey('fake-image-camera'),
children: [
TextButton(
key: const ValueKey('fake-image-camera-close'),
onPressed: onClosed,
child: const Text('Close fake camera'),
),
TextButton(
key: const ValueKey('fake-image-camera-accept'),
onPressed: () => onAccepted(
Uint8List.fromList(image.encodeJpg(image.Image(width: 8, height: 8))),
),
child: const Text('Accept fake photo'),
),
],
);
}