Files
buzz/mobile/test/shared/hosted/hosted_profile_offer_test.dart
klopez4212 f7708d0694 Polish mobile onboarding and profile setup (#8438)
Originally stacked on #8375, now merged. Rebased onto main.

Polishes the onboarding landing page, community-link entry, and recovery
sheets. Profile setup uses a consistent sheet with a larger avatar,
inline display name, and draft photo/emoji/animated editing; iOS uses
native controls and coordinated avatar/sheet transitions.

Validation: mobile analysis, full tests, and normal pre-push checks
passed after rebase. Prior iOS build and device walkthrough passed;
native validation was not repeated after this rebase. Snapshots omitted
as requested.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
2026-10-22 17:27:38 +00:00

222 lines
6.2 KiB
Dart

import 'dart:async';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/shared/hosted/hosted_flow_provider.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/hosted_flow.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../helpers/hosted_communities_fixture.dart';
final _offerProvider = Provider(
(ref) =>
(
HostedFlow flow,
BuzzDeepLink link,
String pubkey,
bool Function() current,
) => offerHostedProfile(ref, flow, link, pubkey, current),
);
class _Profile extends ProfileNotifier {
_Profile(this.load, this.writes);
final Future<UserProfile?> Function() load;
final List<String> writes;
@override
Future<UserProfile?> build() => load();
@override
Future<void> updateDisplayName(String name) async => writes.add(name);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late HostedCommunitiesFixture f;
late CommunityStorage storage;
late ProviderContainer container;
late Community community;
late Future<UserProfile?> Function() load;
late List<String> writes;
var current = true;
var reads = 0;
const link = AddCommunityDeepLink(
relayUrl: 'wss://joined.communities.buzz.xyz',
name: 'Joined',
);
setUp(() async {
FlutterSecureStorage.setMockInitialValues({});
f = HostedCommunitiesFixture();
await f.initialize();
storage = CommunityStorage();
community = Community.create(
name: 'Joined',
relayUrl: link.relayUrl,
pubkey: f.keys.public,
nsec: f.keys.nsec,
);
await storage.save(community);
await storage.saveActiveId(community.id);
writes = [];
reads = 0;
current = true;
load = () async => null;
container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
profileProvider.overrideWith(
() => _Profile(() {
reads++;
return load();
}, writes),
),
],
);
});
tearDown(() async {
container.dispose();
await f.dispose();
});
Future<void> offer() => container.read(_offerProvider)(
f.flow,
link,
f.keys.public,
() => current,
);
Future<void> prompted() async {
for (var i = 0; i < 50 && f.flow.stage != HostedFlowStage.profile; i++) {
await Future<void>.delayed(Duration.zero);
}
expect(f.flow.stage, HostedFlowStage.profile);
}
test('account cancellation while loading prevents prompt', () async {
final loading = Completer<UserProfile?>();
load = () => loading.future;
final pending = offer();
while (reads == 0) {
await Future<void>.delayed(Duration.zero);
}
current = false;
loading.complete(null);
await pending;
expect(f.flow.stage, HostedFlowStage.idle);
expect(writes, isEmpty);
});
test('changed active community blocks a pending save', () async {
final pending = offer();
await prompted();
final other = Community.create(
name: 'Other',
relayUrl: 'wss://other.example',
pubkey: f.keys.public,
nsec: f.keys.nsec,
);
await storage.save(other);
await storage.saveActiveId(other.id);
container.invalidate(communityListProvider);
container.invalidate(activeCommunityProvider);
expect(
(await container.read(activeCommunityProvider.future))?.id,
other.id,
);
await expectLater(
f.flow.saveProfile!('Wrong scope'),
throwsA(AccountFailure.unavailable),
);
expect(writes, isEmpty);
f.flow.finishProfile();
await pending;
});
test(
'new target releases pending profile and rejects retained save',
() async {
final pending = f.flow.requestProfile((name) async => writes.add(name));
final save = f.flow.saveProfile!;
// Unsupported environment finishes locally without browser or network IO.
final next = f.flow.start(
const AddCommunityDeepLink(
relayUrl: 'wss://other.example',
name: 'Other',
),
);
await pending.timeout(const Duration(seconds: 2));
await next;
await expectLater(
save('Stale prompt'),
throwsA(AccountFailure.unavailable),
);
expect(writes, isEmpty);
expect(f.flow.stage, isNot(HostedFlowStage.profile));
},
);
test(
'profile draft retries without completing and skip does not prepare media',
() async {
var prepares = 0;
var attempts = 0;
final pending = f.flow.requestProfile(
(_) async {},
saveDraft: (name, prepare) async {
await prepare?.call();
if (++attempts == 1) throw StateError('offline');
writes.add(name);
},
);
final save = f.flow.saveProfileDraft!;
Future<String> prepare() async {
prepares++;
return 'https://media.example/photo';
}
await expectLater(save('Name', prepare), throwsStateError);
expect(f.flow.stage, HostedFlowStage.profile);
await save('Name', prepare);
await pending;
expect(writes, ['Name']);
expect(prepares, 2);
final skipped = f.flow.requestProfile(
(_) async {},
saveDraft: (_, prepare) async {
await prepare?.call();
},
);
final stale = f.flow.saveProfileDraft!;
f.flow.finishProfile();
await skipped;
await expectLater(
stale('Old', prepare),
throwsA(AccountFailure.unavailable),
);
expect(prepares, 2);
},
);
for (final cancelAdmission in [false, true]) {
test(
'leave rejects captured save (cancel admission: $cancelAdmission)',
() async {
final pending = offer();
await prompted();
final save = f.flow.saveProfile!;
if (cancelAdmission) current = false;
await f.flow.leave();
await pending;
await expectLater(
save('After leave'),
throwsA(AccountFailure.unavailable),
);
expect(writes, isEmpty);
},
);
}
}