Files
buzz/mobile/test/features/profile/settings_profile_header_test.dart
klopez4212 33f54de2dd feat(mobile): organize community and profile settings (#8056)
## Summary

Move Invite, Appearance, and Remove community into a **Community
settings** section in the community-avatar sheet, above the existing
switcher. Keep the sheet untitled and use circle-minus for removal.

Personal Settings groups **Set status** and **Copy public key (npub)**
above an untitled card containing **Display name**, **Profile
description**, and **Edit photo**. Remove the leading icons from status,
public key, and Send identity to desktop; keep the latter in its own
untitled card. Remove the avatar status badge and top Edit action, and
rename the Theme page to **Appearance**. Opening the presence chip
triggers selection haptics; copying plays native iOS success feedback
and confirms with “Public key (npub) copied.”

### Testing

- Mobile formatting, analysis, and file-size checks passed.
- 111 affected widget tests passed; all 48 settings tests passed again
after the final labels and icon changes.
- Final release build installed and launched on a physical iPhone in the
separate Buzz Community preview, retaining its existing test account.
- Full repository `just ci` was blocked by local disk exhaustion during
Rust compilation. The full mobile suite passed (2,609 passed, four
skipped), plus three push-unconfigured tests. The presence and copy
feedback passed focused widget tests and analysis, including native iOS
success-channel coverage.

### Snapshots

Light and dark Flutter widget renders with fixture data. Images are
GitHub attachments, not committed to the repository. The
personal-settings snapshots precede the final “(npub)” label
clarification.

| Surface | Light | Dark |
| --- | --- | --- |
| Community sheet | <img
src="https://github.com/user-attachments/assets/d52a192e-bd40-4263-ac2d-05d589c7ebf2"
width="280" alt="Community sheet, light"> | <img
src="https://github.com/user-attachments/assets/8756ebe9-b6d4-4920-8fb7-1cb7ee8ef778"
width="280" alt="Community sheet, dark"> |
| Personal settings | <img
src="https://github.com/user-attachments/assets/962b98bd-e4cf-4cd1-8948-69b349390ceb"
width="280" alt="Personal settings, light"> | <img
src="https://github.com/user-attachments/assets/223bb0a8-e10e-43b1-83ea-e0955af4627a"
width="280" alt="Personal settings, dark"> |
| Appearance | <img
src="https://github.com/user-attachments/assets/334ba797-55cb-47ce-b677-0befafb7659b"
width="280" alt="Appearance, light"> | <img
src="https://github.com/user-attachments/assets/79b62503-d476-4dbc-8c75-8cee0ee37e98"
width="280" alt="Appearance, dark"> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-03 10:06:05 +01:00

502 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);
});
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=',
);