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>
This commit is contained in:
klopez4212
2026-10-22 10:27:38 -07:00
committed by GitHub
parent 8260b6ef4c
commit f7708d0694
45 changed files with 3749 additions and 172 deletions

View File

@@ -158,6 +158,48 @@ void main() {
},
);
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)',

View File

@@ -0,0 +1,174 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/identity_transfer/native_community_entry.dart';
import 'package:buzz/shared/hosted/device_identity_source.dart';
import 'package:nostr/nostr.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('buzz/community_entry');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
late Completer<bool> dismissed;
late Completer<Map<dynamic, dynamic>> presented;
setUp(() {
dismissed = Completer<bool>();
presented = Completer<Map<dynamic, dynamic>>();
messenger.setMockMethodCallHandler(channel, (call) async {
presented.complete(call.arguments as Map);
return dismissed.future;
});
});
tearDown(() => messenger.setMockMethodCallHandler(channel, null));
Future<Map<dynamic, dynamic>?> action(
String method,
Map<String, dynamic> args,
) async {
final result = Completer<Map<dynamic, dynamic>?>();
messenger.handlePlatformMessage(
channel.name,
const StandardMethodCodec().encodeMethodCall(MethodCall(method, args)),
(data) => result.complete(
const StandardMethodCodec().decodeEnvelope(data!) as Map?,
),
);
return result.future;
}
for (final code in [
'presentation_failed',
'invalid_arguments',
'already_presented',
]) {
test('native $code preserves the presentation contract', () async {
messenger.setMockMethodCallHandler(channel, (_) async {
throw PlatformException(code: code);
});
final result = NativeCommunityEntry.show(
name: 'Preview',
dark: false,
primaryArgb: 0xFF007AFF,
choices: const [],
connect: (_, _, _, _) async => fail('Must not connect'),
);
if (code == 'presentation_failed') {
expect(await result, isNull);
// A failed presentation must not leave the singleton busy.
messenger.setMockMethodCallHandler(channel, (_) async => true);
expect(
await NativeCommunityEntry.show(
name: 'Preview',
dark: false,
primaryArgb: 0xFF007AFF,
choices: const [],
connect: (_, _, _, _) async {},
),
isTrue,
);
} else {
await expectLater(
result,
throwsA(isA<PlatformException>().having((e) => e.code, 'code', code)),
);
}
});
}
test(
'invite confirmation delegates claiming and preserves retry errors',
() async {
var claims = 0;
final shown = NativeCommunityEntry.show(
name: 'preview.example.com',
dark: false,
primaryArgb: 0xFF000000,
choices: const [],
connect: (_, _, _, _) async => fail('Must use invite claiming'),
confirmInvite: () async =>
++claims == 1 ? {'error': 'Try again'} : {'joined': true},
);
final config = await presented.future;
expect(config['invite'], true);
final args = <String, dynamic>{'session': config['session']};
expect((await action('join', args))?['error'], 'Try again');
expect((await action('join', args))?['joined'], true);
dismissed.complete(true);
expect(await shown, true);
},
);
test(
'native import requires preview and confirmation before connecting',
() async {
final keys = Keys('1'.padLeft(64, '0'));
var joins = 0;
final shown = NativeCommunityEntry.show(
name: 'Preview',
dark: false,
primaryArgb: 0xFF007AFF,
choices: [],
connect: (_, nsec, create, current) async {
expect(nsec, keys.nsec);
expect(create, false);
expect(current(), true);
joins++;
},
);
final config = await presented.future;
final args = <String, dynamic>{
'session': config['session'],
'nsec': keys.nsec,
};
expect((await action('join', args))?['error'], isNotNull);
expect(joins, 0);
expect((await action('preview', args))?['npub'], startsWith('npub1'));
expect(joins, 0);
expect((await action('join', args))?['joined'], true);
expect(joins, 1);
dismissed.complete(true);
expect(await shown, true);
},
);
test(
'returning to choices fences an in-flight join and allows retry',
() async {
final pending = Completer<void>();
final began = Completer<void>();
var calls = 0;
var staleWasCurrent = true;
final shown = NativeCommunityEntry.show(
name: 'Preview',
dark: false,
primaryArgb: 0xFF007AFF,
choices: const [DeviceIdentityChoice('pubkey', 'Saved')],
connect: (_, _, _, current) async {
calls++;
if (calls == 1) {
began.complete();
await pending.future;
staleWasCurrent = current();
}
},
);
final config = await presented.future;
final args = <String, dynamic>{
'session': config['session'],
'pubkey': 'pubkey',
};
final first = action('join', args);
await began.future;
await action('cancel', args);
pending.complete();
expect((await first)?['cancelled'], true);
expect(staleWasCurrent, false);
expect((await action('join', args))?['joined'], true);
expect(calls, 2);
dismissed.complete(true);
expect(await shown, true);
},
);
}

View File

@@ -1,6 +1,7 @@
import 'package:buzz/shared/hosted/account_identity_store.dart';
import 'package:buzz/shared/identity_transfer/nsec_import_sheet.dart';
import 'package:buzz/shared/utils/string_utils.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
@@ -8,32 +9,78 @@ import 'package:nostr/nostr.dart' as nostr;
void main() {
final keys = nostr.Keys('1'.padLeft(64, '0'));
testWidgets(
'paste requires public preview then confirmation before secure save',
(tester) async {
String? imported;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: NsecImportSheet(
onImport: (nsec) async {
imported = nsec;
},
for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) {
testWidgets(
'$platform paste requires public preview then confirmation before secure save',
(tester) async {
String? imported;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showNsecImportSheet(
context,
onImport: (nsec) async {
imported = nsec;
},
),
child: const Text('Open import'),
),
),
),
),
);
await tester.tap(find.text('Open import'));
await tester.pumpAndSettle();
final input = platform == TargetPlatform.iOS
? find.byType(CupertinoTextField)
: find.byType(TextField);
if (platform == TargetPlatform.iOS) {
final field = tester.widget<CupertinoTextField>(input);
expect(field.obscureText, isTrue);
expect(field.autocorrect, isFalse);
expect(field.enableIMEPersonalizedLearning, isFalse);
}
await tester.enterText(input, keys.nsec);
await tester.tap(find.text('Preview identity'));
await tester.pumpAndSettle();
expect(find.text(fullNpub(keys.public)!), findsOneWidget);
expect(imported, isNull);
await tester.ensureVisible(find.text('Import this identity'));
await tester.tap(find.text('Import this identity'));
await tester.pumpAndSettle();
expect(imported, keys.nsec);
expect(find.text('Open import'), findsOneWidget);
expect(find.text('Private key (nsec)'), findsNothing);
},
variant: TargetPlatformVariant({platform}),
);
}
testWidgets('iOS import waits for its containing sheet before focusing', (
tester,
) async {
Future<void> show(bool autofocus) => tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: NsecImportSheet(autofocus: autofocus, onImport: (_) async {}),
),
);
await tester.enterText(find.byType(TextField), keys.nsec);
await tester.tap(find.text('Preview identity'));
await tester.pumpAndSettle();
expect(find.text(fullNpub(keys.public)!), findsOneWidget);
expect(imported, isNull);
await tester.tap(find.text('Import this identity'));
await tester.pumpAndSettle();
expect(imported, keys.nsec);
await tester.pump();
},
);
),
);
await show(false);
await tester.pump();
expect(
tester.widget<EditableText>(find.byType(EditableText)).focusNode.hasFocus,
isFalse,
);
await show(true);
await tester.pump();
expect(
tester.widget<EditableText>(find.byType(EditableText)).focusNode.hasFocus,
isTrue,
);
}, variant: TargetPlatformVariant({TargetPlatform.iOS}));
testWidgets('file import previews and occupied identity stays unchanged', (
tester,