Unify mobile onboarding and community entry screens (#8687)

## Summary

Use consistent fullscreen screens for identity setup, community
selection, joining, and Add Community. Restore all memberships and open
the selected community; share the community grid with the switcher.

Add QR-first choices, animated link entry with 12px keyboard clearance,
a two-finger double-tap pairing toggle, and the centered reversing bee
loader. Preserve cancellation, Back, and reduced-motion behavior.

Add intentionally omits community creation and Builderlab sign-in: this
route follows sign-in and the existing-community picker.

### Related issue

Related: #8628 (native profile setup). No duplicate fullscreen
onboarding PR found.

### Testing

- Full standard and hosted-authentication mobile suites, static
analysis, and required push checks passed.
- iPhone preview iterated with Kenny; final simulator walkthrough below
uses synthetic data and does not authenticate or save account changes.
- Independent review completed; regressions cover cancellation during
loading, Add recovery after failed/conflicting memberships, retrying
skipped communities, and explicit picker stages.

Walkthrough recorded before the copy and recovery followups:


https://github.com/user-attachments/assets/bfea3ad3-1dde-40c4-8748-44c84226c292

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-29 16:12:31 +00:00
committed by GitHub
parent 8abcf2ea2a
commit e922dc08ff
41 changed files with 2923 additions and 857 deletions

View File

@@ -300,9 +300,9 @@ private final class NativeProfileSetupController: UIViewController, UITextFieldD
NSLayoutConstraint.activate([
scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scroll.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
stack.heightAnchor.constraint(greaterThanOrEqualTo: scroll.frameLayoutGuide.heightAnchor, constant: -40),
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 20), stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -20),
stack.leadingAnchor.constraint(equalTo: scroll.frameLayoutGuide.leadingAnchor, constant: 20), stack.trailingAnchor.constraint(equalTo: scroll.frameLayoutGuide.trailingAnchor, constant: -20)
stack.heightAnchor.constraint(greaterThanOrEqualTo: scroll.frameLayoutGuide.heightAnchor, constant: -48),
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 24), stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -24),
stack.leadingAnchor.constraint(equalTo: scroll.frameLayoutGuide.leadingAnchor, constant: 24), stack.trailingAnchor.constraint(equalTo: scroll.frameLayoutGuide.trailingAnchor, constant: -24)
])
let avatarMinimumHeight = avatarRow.heightAnchor.constraint(greaterThanOrEqualToConstant: avatarSize)
avatarMinimumHeight.priority = .defaultHigh
@@ -341,8 +341,8 @@ private final class NativeProfileSetupController: UIViewController, UITextFieldD
explanationRow.addArrangedSubview(explanation)
explanationRow.isLayoutMarginsRelativeArrangement = true
stack.addArrangedSubview(explanationRow)
// Match Grid.xxs between onboarding titles and subtitles.
stack.setCustomSpacing(8, after: heading)
// Match the shared onboarding title/subtitle spacing.
stack.setCustomSpacing(16, after: heading)
stack.addArrangedSubview(name)
status.numberOfLines = 0; status.textColor = .systemRed; status.font = .preferredFont(forTextStyle: .footnote); status.isHidden = true; stack.addArrangedSubview(status)
stack.addArrangedSubview(avatarRow)
@@ -367,6 +367,41 @@ private final class NativeProfileSetupController: UIViewController, UITextFieldD
stack.setCustomSpacing(8, after: submit)
showStep(avatar: false, announce: false)
}
// Same Inter metrics as OnboardingTypography in Flutter.
private static let interFontName: String? = {
guard let frameworks = Bundle.main.privateFrameworksURL,
let provider = CGDataProvider(url: frameworks.appendingPathComponent("App.framework/flutter_assets/assets/fonts/InterVariable.ttf") as CFURL),
let font = CGFont(provider), let name = font.postScriptName else { return nil }
CTFontManagerRegisterGraphicsFont(font, nil)
return name as String
}()
private func updateHeadingTypography() {
for (label, size, lineHeight, tracking, style) in [
(heading, CGFloat(30), CGFloat(36), CGFloat(-1), UIFont.TextStyle.largeTitle),
(explanation, CGFloat(16), CGFloat(24), CGFloat(-0.25), UIFont.TextStyle.body)
] {
let metrics = UIFontMetrics(forTextStyle: style)
let base = Self.interFontName.flatMap { UIFont(name: $0, size: size) }
?? UIFont.systemFont(ofSize: size, weight: .regular)
let font = metrics.scaledFont(for: base, compatibleWith: traitCollection)
let paragraph = NSMutableParagraphStyle()
paragraph.minimumLineHeight = metrics.scaledValue(for: lineHeight, compatibleWith: traitCollection)
label.font = font
label.attributedText = NSAttributedString(string: label.text ?? "", attributes: [
.font: font, .paragraphStyle: paragraph,
.kern: tracking * font.pointSize / size
])
}
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory {
updateHeadingTypography()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
focusNameIfNeeded()
@@ -381,13 +416,7 @@ private final class NativeProfileSetupController: UIViewController, UITextFieldD
explanation.text = avatar
? "Let’s choose an avatar so people know it’s you."
: "Your display name is how people see you in Buzz. You can change it any time."
// Mobile bodyLarge subtitle: 16/20, regular, subtle.
let paragraph = NSMutableParagraphStyle()
paragraph.minimumLineHeight = UIFontMetrics(forTextStyle: .subheadline).scaledValue(for: 20)
explanation.attributedText = NSAttributedString(string: explanation.text ?? "", attributes: [
.paragraphStyle: paragraph,
.kern: 0
])
updateHeadingTypography()
explanationRow.directionalLayoutMargins = .zero
name.isHidden = avatar
avatarRow.isHidden = !avatar

View File

@@ -20,6 +20,9 @@ import 'package:buzz/shared/theme/buzz_icons.dart';
import '../../shared/auth/auth.dart';
import '../../shared/community/community_icon_provider.dart';
import '../../shared/community/community_avatar.dart';
import '../../shared/community/community_grid.dart';
import '../../shared/community/community_grid_option.dart';
import '../../shared/community/community_grid_add.dart';
import '../../shared/community/paired_community_landing.dart';
import '../../shared/community/community_membership_provider.dart';
import '../../shared/widgets/app_list_card_item.dart';

View File

@@ -1,6 +1,6 @@
part of '../channels_page.dart';
const _communityGridAvatarSize = 88.0;
const _communityGridAvatarSize = communityGridAvatarSize;
const _communityCenterDuration = Duration(milliseconds: 420);
const _communityCenterCurve = Cubic(0.22, 1, 0.36, 1);
const _communityDepartureDuration = Duration(milliseconds: 520);
@@ -338,11 +338,13 @@ class CommunitySwitcherPage extends HookConsumerWidget {
),
),
error: (e, _) => Text('Error loading communities: $e'),
data: (communities) => _CommunityGrid(
data: (communities) => CommunityGrid(
children: [
for (final community in communities)
_CommunityGridTile(
community: community,
CommunityGridOption(
id: community.id,
name: community.name,
relayUrl: community.relayUrl,
// Keep the current-community marker stable
// while the selected avatar leaves the grid.
isActive:
@@ -350,17 +352,27 @@ class CommunitySwitcherPage extends HookConsumerWidget {
(flight.value == null
? activeId
: flight.value!.previousCommunityId),
isEditing: isEditing.value,
enabled: !isEditing.value,
hidden: flight.value?.community.id == community.id,
onSelect: selectCommunity,
onRemove: () => _confirmRemoveCommunity(
context,
ref,
community,
closeSheetAfterRemoval: community.id == activeId,
onSelect: (origin) =>
selectCommunity(community, origin),
badge: _CommunityGridAction(
community: community,
isActive:
community.id ==
(flight.value == null
? activeId
: flight.value!.previousCommunityId),
isEditing: isEditing.value,
onRemove: () => _confirmRemoveCommunity(
context,
ref,
community,
closeSheetAfterRemoval: community.id == activeId,
),
),
),
_CommunityGridAdd(onTap: addCommunity),
CommunityGridAdd(onTap: addCommunity),
],
),
),
@@ -544,162 +556,3 @@ class _CommunityFlyingAvatar extends StatelessWidget {
);
}
}
/// Intrinsic-height rows keep two columns without clipping scaled labels.
class _CommunityGrid extends StatelessWidget {
const _CommunityGrid({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) => Column(
key: const Key('community-switcher-options'),
mainAxisSize: MainAxisSize.min,
children: [
for (var index = 0; index < children.length; index += 2)
Padding(
padding: const EdgeInsets.only(bottom: Grid.md),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: children[index]),
const SizedBox(width: Grid.xs),
Expanded(
child: index + 1 < children.length
? children[index + 1]
: const SizedBox.shrink(),
),
],
),
),
],
);
}
class _CommunityGridTile extends HookWidget {
const _CommunityGridTile({
required this.community,
required this.isActive,
required this.isEditing,
required this.hidden,
required this.onSelect,
required this.onRemove,
});
final Community community;
final bool isActive;
final bool isEditing;
final bool hidden;
final Future<void> Function(Community, Rect) onSelect;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final avatarKey = useMemoized(GlobalKey.new);
return Semantics(
selected: isActive,
child: InkWell(
key: Key('community-switcher-row-${community.id}'),
borderRadius: BorderRadius.circular(Radii.card),
onTap: isEditing
? null
: () {
final box = avatarKey.currentContext?.findRenderObject();
if (box is RenderBox) {
unawaited(
onSelect(
community,
box.localToGlobal(Offset.zero) & box.size,
),
);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Opacity(
opacity: hidden ? 0 : 1,
child: SizedBox(
key: avatarKey,
child: CommunityAvatar(
key: Key('community-switcher-avatar-${community.id}'),
name: community.name,
relayUrl: community.relayUrl,
size: _communityGridAvatarSize,
),
),
),
Positioned(
right: -Grid.twelve,
bottom: -Grid.twelve,
child: _CommunityGridAction(
community: community,
isActive: isActive,
isEditing: isEditing,
onRemove: onRemove,
),
),
],
),
const SizedBox(height: Grid.xs),
Text(
community.name,
textAlign: TextAlign.center,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
}
class _CommunityGridAdd extends StatelessWidget {
const _CommunityGridAdd({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) => InkWell(
key: const Key('community-switcher-add'),
borderRadius: BorderRadius.circular(Radii.card),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
children: [
ClipRSuperellipse(
borderRadius: BorderRadius.circular(
Radii.card * _communityGridAvatarSize / 40,
),
child: Container(
width: _communityGridAvatarSize,
height: _communityGridAvatarSize,
color: context.controlSurface.withValues(alpha: 0.5),
child: Icon(
BuzzIcons.plus,
size: 32,
color: context.colors.onSurfaceVariant,
),
),
),
const SizedBox(height: Grid.xs),
Text(
'Add Community',
textAlign: TextAlign.center,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}

View File

@@ -4,38 +4,88 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/hosted/hosted_flow.dart';
import '../../shared/theme/grid.dart';
import '../../shared/community/community_grid.dart';
import '../../shared/community/community_grid_option.dart';
import '../../shared/community/community_grid_add.dart';
import '../../onboarding_app_entry.dart';
import '../../shared/widgets/onboarding_step_page.dart';
/// Public community choices only; identity material remains in secure storage.
/// Community selection is completed before admission reveals Home.
class HostedCommunitySelection extends HookConsumerWidget {
const HostedCommunitySelection({super.key, required this.flow});
const HostedCommunitySelection({
super.key,
required this.flow,
this.onAddCommunity,
this.fullScreen = false,
this.onBack,
});
final HostedFlow flow;
final VoidCallback? onAddCommunity;
final bool fullScreen;
final VoidCallback? onBack;
@override
Widget build(BuildContext context, WidgetRef ref) {
useListenable(flow);
return Material(
type: MaterialType.transparency,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final community in flow.communities)
CheckboxListTile(
key: ValueKey(community.relayUrl),
title: Text(community.name),
subtitle: Text(Uri.parse(community.relayUrl).host),
value: flow.selectedRelays.contains(community.relayUrl),
onChanged: (value) =>
flow.selectCommunity(community.relayUrl, value ?? false),
),
const SizedBox(height: Grid.sm),
FilledButton(
onPressed: flow.selectedRelays.isEmpty
? null
: flow.addSelectedCommunities,
child: const Text('Continue'),
Future<void> addCommunity() async {
final navigator = Navigator.of(context, rootNavigator: true);
if (!await flow.prepareToAddCommunity() || !navigator.mounted) return;
late final MaterialPageRoute<void> route;
var exiting = false;
Future<void> exit() async {
if (exiting) return;
exiting = true;
await flow.signInAccount();
if (flow.stage == HostedFlowStage.signIn ||
flow.stage == HostedFlowStage.idle) {
exiting = false;
return;
}
if (route.isActive) route.navigator?.removeRoute(route);
}
route = MaterialPageRoute<void>(
builder: (_) => OnboardingAppEntry(
addingCommunity: true,
restoreAccount: false,
onExit: exit,
),
);
navigator.push(route);
// Mount the opaque entry route before retiring the setup cover.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (route.isActive) flow.leave();
});
}
final choices = CommunityGrid(
children: [
for (final community in flow.communities)
CommunityGridOption(
id: community.relayUrl,
name: community.name,
relayUrl: community.relayUrl,
onSelect: (_) async {
flow.chooseLandingCommunity(community.relayUrl);
await flow.addSelectedCommunities();
},
),
],
),
CommunityGridAdd(onTap: onAddCommunity ?? addCommunity),
],
);
if (fullScreen) {
return OnboardingStepPage(
title: 'Select a community',
onBack: onBack ?? () {},
content: choices,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
choices,
const SizedBox(height: Grid.sm),
],
);
}
}

View File

@@ -1,65 +1,155 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/hosted/hosted_flow.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/theme/theme_extensions.dart';
import 'hosted_connect_sheet.dart';
/// Presents setup with the same modal chrome as the app's other sheets.
/// Owns a full-screen route above both initial entry and pushed Add Community.
class HostedConnectPage extends HookConsumerWidget {
const HostedConnectPage({
super.key,
required this.flow,
this.onAddCommunity,
required this.onClose,
required this.profileBuilder,
});
final HostedFlow flow;
final VoidCallback? onAddCommunity;
final VoidCallback onClose;
final WidgetBuilder profileBuilder;
@override
Widget build(BuildContext context, WidgetRef ref) {
final latest = useRef(this)..value = this;
useEffect(() {
var active = true;
ModalRoute<dynamic>? sheetRoute;
WidgetsBinding.instance.addPostFrameCallback((_) async {
Route<void>? route;
final stepsNavigator = GlobalKey<NavigatorState>();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!active || !context.mounted) return;
while (active &&
context.mounted &&
flow.stage != HostedFlowStage.complete &&
flow.stage != HostedFlowStage.idle) {
if (!context.mounted) return;
await showBuzzModalBottomSheet<void>(
context: context,
title: 'Connect to Builderlab',
useRootNavigator: true,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) {
sheetRoute = ModalRoute.of(sheetContext);
return HostedConnectSheet(flow: flow);
route = PageRouteBuilder<void>(
settings: const RouteSettings(name: 'hosted-setup'),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
pageBuilder: (_, _, _) => PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) stepsNavigator.currentState?.maybePop();
},
);
if (!active || !context.mounted) return;
if (flow.stage != HostedFlowStage.profile) {
onClose();
return;
}
// Dismissing an optional profile is a skip. Re-present the continuing
// batch so a later error or choice never ends up behind a closed sheet.
flow.finishProfile();
await WidgetsBinding.instance.endOfFrame;
}
child: Navigator(
key: stepsNavigator,
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (_) => _HostedSteps(
flow: flow,
onAddCommunity: onAddCommunity,
onClose: () => latest.value.onClose(),
profileBuilder: (context) =>
latest.value.profileBuilder(context),
),
),
),
),
);
unawaited(
Navigator.of(context, rootNavigator: true).push(route!).then((_) {
if (active &&
flow.stage != HostedFlowStage.complete &&
flow.stage != HostedFlowStage.idle) {
latest.value.onClose();
}
}),
);
});
return () {
active = false;
final route = sheetRoute;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (route != null && route.isActive) {
route.navigator?.removeRoute(route);
final current = route;
if (current != null && current.isActive) {
current.navigator?.removeRoute(current);
}
});
};
}, [flow]);
return const SizedBox.shrink();
return ColoredBox(color: context.colors.surface);
}
}
class _HostedSteps extends HookWidget {
const _HostedSteps({
required this.flow,
this.onAddCommunity,
required this.onClose,
required this.profileBuilder,
});
final HostedFlow flow;
final VoidCallback? onAddCommunity;
final VoidCallback onClose;
final WidgetBuilder profileBuilder;
@override
Widget build(BuildContext context) {
useListenable(flow);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final presentedStage = flow.stage;
final presentation = Object();
final activePresentation = useRef<Object?>(null)..value = presentation;
// Keep an opaque surface behind the native profile presentation and its
// dismissal. Completion retires the route only after the flow settles.
if (flow.stage == HostedFlowStage.profile) {
return ColoredBox(
color: context.colors.surface,
child: profileBuilder(context),
);
}
return ColoredBox(
color: context.colors.surface,
child: AnimatedSwitcher(
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 250),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: (current, previous) => Stack(
fit: StackFit.expand,
children: [
for (final child in previous)
ExcludeFocus(
child: ExcludeSemantics(child: IgnorePointer(child: child)),
),
?current,
],
),
transitionBuilder: (child, animation) => SlideTransition(
position: Tween(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: child,
),
child: HostedConnectSheet(
key: ValueKey(flow.stage),
flow: flow,
onAddCommunity: onAddCommunity,
presentedStage: flow.stage,
fullScreen: true,
onBack: () {
// Outgoing pages remain mounted until their transition finishes.
if (activePresentation.value != presentation ||
flow.stage != presentedStage) {
return;
}
if (flow.canChangeCommunities) {
flow.backToCommunityChoices();
} else {
onClose();
}
},
),
),
);
}
}

View File

@@ -1,8 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/hosted/hosted_flow.dart';
import '../../shared/widgets/onboarding_step_page.dart';
import '../../shared/widgets/bee_loading_mark.dart';
import '../../shared/widgets/onboarding_loading_page.dart';
import '../../shared/identity_transfer/nsec_import_sheet.dart';
import '../../shared/hosted/hosted_flow_problem_message.dart';
import '../../shared/utils/string_utils.dart';
@@ -14,21 +18,48 @@ import 'hosted_profile_setup.dart';
/// Presents the app-scoped real flow without retaining credentials or keys.
class HostedConnectSheet extends HookConsumerWidget {
const HostedConnectSheet({super.key, required this.flow});
const HostedConnectSheet({
super.key,
required this.flow,
this.onAddCommunity,
this.fullScreen = false,
this.onBack,
this.presentedStage,
});
final HostedFlow flow;
final VoidCallback? onAddCommunity;
final bool fullScreen;
final VoidCallback? onBack;
final HostedFlowStage? presentedStage;
@override
Widget build(BuildContext context, WidgetRef ref) {
useListenable(flow);
final stage = presentedStage ?? flow.stage;
final showLinkedIdentity = useState(false);
Future<void> importIdentity() async {
Future<void>? pendingImport;
await showNsecImportSheet(
context,
mismatchMessage:
'This key doesn’t match the identity linked to your Builderlab account.',
onImport: (nsec) => pendingImport = flow.importIdentity(nsec),
);
Future<void> save(String nsec) =>
pendingImport = flow.importIdentity(nsec);
const mismatch =
'This key doesn’t match the identity linked to your Builderlab account.';
if (fullScreen) {
await Navigator.of(context).push<bool>(
CupertinoPageRoute(
builder: (_) => NsecImportSheet(
onImport: save,
mismatchMessage: mismatch,
fullScreen: true,
),
),
);
} else {
await showNsecImportSheet(
context,
mismatchMessage: mismatch,
onImport: save,
);
}
// Dismissing the sheet does not cancel a secure-store write in flight.
// Finish that operation before resuming, even when the modal returned null.
if (pendingImport == null) return;
@@ -46,8 +77,8 @@ class HostedConnectSheet extends HookConsumerWidget {
minimumSize: const Size.fromHeight(52),
shape: const StadiumBorder(),
);
final working = flow.stage == HostedFlowStage.working;
final message = switch (flow.stage) {
final working = stage == HostedFlowStage.working;
final message = switch (stage) {
HostedFlowStage.idle => 'Continue connecting your account.',
HostedFlowStage.working =>
flow.addingCommunityName == null
@@ -55,9 +86,9 @@ class HostedConnectSheet extends HookConsumerWidget {
: 'Adding ${flow.addingCommunityName}…',
HostedFlowStage.setupIdentity =>
flow.canCreateIdentity
? 'Create an identity, or bring yours from another device. We’ll back it up for future sign-ins.'
? 'Your Buzz identity is how communities recognize you. Create a new one, or import your existing identity if you already use Buzz.'
: 'Your identity hasn’t been backed up yet. Bring it from your other device to keep using the same Buzz account.',
HostedFlowStage.chooseCommunities => 'Choose your communities',
HostedFlowStage.chooseCommunities => 'Select a community',
HostedFlowStage.communityConflict =>
'${flow.addingCommunityName} is already saved with a different identity. '
'Keep that identity and skip this addition, or return to your choices?',
@@ -77,161 +108,193 @@ class HostedConnectSheet extends HookConsumerWidget {
HostedFlowStage.complete => 'Your community is ready.',
HostedFlowStage.blocked => hostedFlowProblemMessage(flow.problem),
};
return Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.sm,
Grid.gutter,
Grid.lg,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (flow.stage == HostedFlowStage.setupIdentity) ...[
Text(
flow.canCreateIdentity
? 'Make yourself at home'
: 'Bring your identity with you',
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.twelve),
],
Text(message, style: context.textTheme.bodyLarge),
const SizedBox(height: Grid.sm),
for (final choice in flow.identityChoices)
Padding(
padding: const EdgeInsets.only(bottom: Grid.sm),
child: OutlinedButton(
onPressed: () => flow.chooseIdentity(choice.pubkey),
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Column(
children: [
Text(choice.label),
Text(fullNpub(choice.pubkey) ?? choice.pubkey),
],
),
),
),
),
if (flow.stage == HostedFlowStage.setupIdentity) ...[
if (flow.canCreateIdentity) ...[
FilledButton(
style: actionStyle.copyWith(
backgroundColor: WidgetStatePropertyAll(
context.colors.primary,
),
foregroundColor: WidgetStatePropertyAll(
context.colors.onPrimary,
),
),
onPressed: flow.createIdentity,
child: const Text('Create my Buzz identity'),
),
const SizedBox(height: Grid.xxs),
TextButton(
style: actionStyle,
onPressed: importIdentity,
child: const Text('Import existing identity'),
),
] else ...[
FilledButton(
style: actionStyle.copyWith(
backgroundColor: WidgetStatePropertyAll(
context.colors.primary,
),
foregroundColor: WidgetStatePropertyAll(
context.colors.onPrimary,
),
),
onPressed: importIdentity,
child: const Text('Import existing identity'),
),
const SizedBox(height: Grid.twelve),
Text(
'We’ll securely back it up so your next sign-in is easier.',
textAlign: TextAlign.center,
style: context.textTheme.bodySmall,
),
],
if (flow.requiredIdentityPubkey != null) ...[
const SizedBox(height: Grid.xxs),
TextButton(
onPressed: () =>
showLinkedIdentity.value = !showLinkedIdentity.value,
child: Text(
showLinkedIdentity.value
? 'Hide linked identity'
: 'View linked identity',
),
),
if (showLinkedIdentity.value)
SelectableText(
fullNpub(flow.requiredIdentityPubkey!) ??
flow.requiredIdentityPubkey!,
style: context.textTheme.bodySmall,
),
],
],
if (flow.stage == HostedFlowStage.profile &&
flow.saveProfile != null)
HostedProfileSetup(
save: flow.saveProfile!,
skip: flow.finishProfile,
),
if (flow.stage == HostedFlowStage.acceptPolicy &&
flow.joinPolicy != null)
HostedJoinPolicyView(
key: ValueKey(flow.joinPolicy!.version),
policy: flow.joinPolicy!,
onAccept: flow.acceptJoinPolicy,
),
if (flow.stage == HostedFlowStage.chooseCommunities)
HostedCommunitySelection(flow: flow),
if (flow.stage == HostedFlowStage.communityConflict)
FilledButton(
onPressed: flow.skipConflictingCommunity,
child: const Text('Keep saved identity and skip'),
),
if (flow.canChangeCommunities)
OutlinedButton(
onPressed: flow.backToCommunityChoices,
child: const Text('Back to choices'),
),
if ((flow.stage == HostedFlowStage.blocked ||
flow.stage == HostedFlowStage.communityConflict) &&
flow.addedCommunityCount > 0)
Text(
'${flow.addedCommunityCount} '
'${flow.addedCommunityCount == 1 ? "community" : "communities"} added and kept.'
'${flow.canRetry && flow.stage == HostedFlowStage.blocked ? " Retry continues the remaining additions." : ""}',
),
if (working) const Center(child: CircularProgressIndicator()),
if (!working &&
flow.stage != HostedFlowStage.profile &&
flow.stage != HostedFlowStage.acceptPolicy &&
flow.stage != HostedFlowStage.chooseIdentity &&
flow.stage != HostedFlowStage.setupIdentity &&
flow.stage != HostedFlowStage.chooseCommunities &&
flow.stage != HostedFlowStage.communityConflict &&
flow.stage != HostedFlowStage.complete &&
flow.stage != HostedFlowStage.browserReturned &&
flow.canRetry)
FilledButton(
onPressed: flow.retry,
child: Text(
flow.stage == HostedFlowStage.signIn
? 'Start login again'
: 'Try again',
),
),
],
if (fullScreen && stage == HostedFlowStage.chooseCommunities) {
return HostedCommunitySelection(
flow: flow,
fullScreen: true,
onAddCommunity: onAddCommunity,
onBack: onBack,
);
}
if (fullScreen &&
stage == HostedFlowStage.acceptPolicy &&
flow.joinPolicy != null) {
return HostedJoinPolicyView(
key: ValueKey(flow.joinPolicy!.version),
policy: flow.joinPolicy!,
onAccept: flow.acceptJoinPolicy,
fullScreen: true,
onBack: onBack,
);
}
final children = <Widget>[
if (!fullScreen && stage == HostedFlowStage.setupIdentity) ...[
Text(
flow.canCreateIdentity
? 'Create or import a Buzz identity'
: 'Bring your identity with you',
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.twelve),
],
if (!fullScreen) Text(message, style: context.textTheme.bodyLarge),
const SizedBox(height: Grid.sm),
for (final choice in flow.identityChoices)
Padding(
padding: const EdgeInsets.only(bottom: Grid.sm),
child: OutlinedButton(
onPressed: () => flow.chooseIdentity(choice.pubkey),
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Column(
children: [
Text(choice.label),
Text(fullNpub(choice.pubkey) ?? choice.pubkey),
],
),
),
),
),
if (stage == HostedFlowStage.setupIdentity) ...[
if (flow.canCreateIdentity) ...[
FilledButton(
style: actionStyle.copyWith(
backgroundColor: WidgetStatePropertyAll(context.colors.primary),
foregroundColor: WidgetStatePropertyAll(context.colors.onPrimary),
),
onPressed: flow.createIdentity,
child: const Text('Create my Buzz identity'),
),
const SizedBox(height: Grid.xxs),
TextButton(
style: actionStyle,
onPressed: importIdentity,
child: const Text('Import existing identity'),
),
] else ...[
FilledButton(
style: actionStyle.copyWith(
backgroundColor: WidgetStatePropertyAll(context.colors.primary),
foregroundColor: WidgetStatePropertyAll(context.colors.onPrimary),
),
onPressed: importIdentity,
child: const Text('Import existing identity'),
),
const SizedBox(height: Grid.twelve),
Text(
'We’ll securely back it up so your next sign-in is easier.',
textAlign: TextAlign.center,
style: context.textTheme.bodySmall,
),
],
if (flow.requiredIdentityPubkey != null) ...[
const SizedBox(height: Grid.xxs),
TextButton(
onPressed: () =>
showLinkedIdentity.value = !showLinkedIdentity.value,
child: Text(
showLinkedIdentity.value
? 'Hide linked identity'
: 'View linked identity',
),
),
if (showLinkedIdentity.value)
SelectableText(
fullNpub(flow.requiredIdentityPubkey!) ??
flow.requiredIdentityPubkey!,
style: context.textTheme.bodySmall,
),
],
],
if (stage == HostedFlowStage.profile && flow.saveProfile != null)
HostedProfileSetup(save: flow.saveProfile!, skip: flow.finishProfile),
if (stage == HostedFlowStage.acceptPolicy && flow.joinPolicy != null)
HostedJoinPolicyView(
key: ValueKey(flow.joinPolicy!.version),
policy: flow.joinPolicy!,
onAccept: flow.acceptJoinPolicy,
),
if (stage == HostedFlowStage.chooseCommunities)
HostedCommunitySelection(flow: flow, onAddCommunity: onAddCommunity),
if (stage == HostedFlowStage.communityConflict)
FilledButton(
onPressed: flow.skipConflictingCommunity,
child: const Text('Keep saved identity and skip'),
),
if (flow.canSkipFailedCommunity)
OutlinedButton(
onPressed: flow.skipFailedCommunity,
child: const Text('Skip this community'),
),
if (flow.canChangeCommunities)
OutlinedButton(
onPressed: flow.backToCommunityChoices,
child: const Text('Back to choices'),
),
if ((stage == HostedFlowStage.blocked ||
stage == HostedFlowStage.communityConflict) &&
flow.addedCommunityCount > 0)
Text(
'${flow.addedCommunityCount} '
'${flow.addedCommunityCount == 1 ? "community" : "communities"} added and kept.'
'${flow.canRetry && stage == HostedFlowStage.blocked ? " Retry continues the remaining additions." : ""}',
),
if (working && !fullScreen) const Center(child: BeeLoadingMark()),
if (!working &&
stage != HostedFlowStage.profile &&
stage != HostedFlowStage.acceptPolicy &&
stage != HostedFlowStage.chooseIdentity &&
stage != HostedFlowStage.setupIdentity &&
stage != HostedFlowStage.chooseCommunities &&
stage != HostedFlowStage.communityConflict &&
stage != HostedFlowStage.complete &&
stage != HostedFlowStage.browserReturned &&
flow.canRetry)
FilledButton(
onPressed: flow.retry,
child: Text(
stage == HostedFlowStage.signIn ? 'Start login again' : 'Try again',
),
),
];
if (!fullScreen) {
return SingleChildScrollView(
padding: const EdgeInsets.all(Grid.gutter),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: children,
),
);
}
final title = switch (stage) {
HostedFlowStage.setupIdentity =>
flow.canCreateIdentity
? 'Create or import a Buzz identity'
: 'Bring your identity with you',
HostedFlowStage.chooseIdentity => 'Choose your identity',
HostedFlowStage.working =>
flow.addingCommunityName == null
? 'Getting ready…'
: 'Joining your community',
HostedFlowStage.blocked => 'Let’s try that again',
HostedFlowStage.communityConflict => 'Keep your saved identity',
HostedFlowStage.browserReturned => 'Continue in your browser',
_ => 'Connect to Builderlab',
};
if (working) return OnboardingLoadingPage(title: title, onCancel: onBack);
bool isAction(Widget widget) => widget is ButtonStyleButton;
return OnboardingStepPage(
title: title,
subtitle: message,
onBack: onBack ?? () {},
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children.where((widget) => !isAction(widget)).toList(),
),
actions: children.where(isAction).toList(),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../../shared/hosted/hosted_join_policy.dart';
import '../../shared/theme/grid.dart';
import '../../shared/widgets/onboarding_step_page.dart';
/// Consent is explicit and scoped to the policy version shown on this screen.
class HostedJoinPolicyView extends HookWidget {
@@ -9,53 +10,84 @@ class HostedJoinPolicyView extends HookWidget {
super.key,
required this.policy,
required this.onAccept,
this.fullScreen = false,
this.onBack,
});
final HostedJoinPolicy policy;
final VoidCallback onAccept;
final bool fullScreen;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
final ageConfirmed = useState(false);
return Material(
type: MaterialType.transparency,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (policy.terms != null)
ExpansionTile(
title: const Text('Terms of service'),
children: [
Padding(
padding: const EdgeInsets.all(Grid.sm),
child: SelectableText(policy.terms!),
),
],
final documents = [
if (policy.terms != null) 'terms',
if (policy.privacy != null) 'privacy policy',
];
final children = <Widget>[
if (policy.terms != null)
ExpansionTile(
tilePadding: fullScreen ? EdgeInsets.zero : null,
expandedCrossAxisAlignment: fullScreen
? CrossAxisAlignment.stretch
: null,
title: const Text('Terms of service'),
children: [
Padding(
padding: fullScreen
? const EdgeInsets.symmetric(vertical: Grid.sm)
: const EdgeInsets.all(Grid.sm),
child: SelectableText(policy.terms!),
),
if (policy.privacy != null)
ExpansionTile(
title: const Text('Privacy policy'),
children: [
Padding(
padding: const EdgeInsets.all(Grid.sm),
child: SelectableText(policy.privacy!),
),
],
],
),
if (policy.privacy != null)
ExpansionTile(
tilePadding: fullScreen ? EdgeInsets.zero : null,
expandedCrossAxisAlignment: fullScreen
? CrossAxisAlignment.stretch
: null,
title: const Text('Privacy policy'),
children: [
Padding(
padding: fullScreen
? const EdgeInsets.symmetric(vertical: Grid.sm)
: const EdgeInsets.all(Grid.sm),
child: SelectableText(policy.privacy!),
),
if (policy.ageRequired)
CheckboxListTile(
value: ageConfirmed.value,
onChanged: (value) => ageConfirmed.value = value ?? false,
title: const Text('I am 18 or older'),
),
const SizedBox(height: Grid.sm),
FilledButton(
onPressed: !policy.ageRequired || ageConfirmed.value
? onAccept
: null,
child: const Text('Accept and join'),
),
],
],
),
if (policy.ageRequired)
CheckboxListTile(
contentPadding: fullScreen ? EdgeInsets.zero : null,
value: ageConfirmed.value,
onChanged: (value) => ageConfirmed.value = value ?? false,
title: const Text('I am 18 or older'),
),
const SizedBox(height: Grid.sm),
FilledButton(
onPressed: !policy.ageRequired || ageConfirmed.value ? onAccept : null,
child: const Text('Accept and join'),
),
];
if (fullScreen) {
return OnboardingStepPage(
title: 'Join your community',
subtitle: documents.isEmpty
? null
: 'Review this community’s ${documents.join(' and ')} before joining.',
onBack: onBack ?? () {},
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children.sublist(0, children.length - 1),
),
actions: [children.last],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
);
}
}

View File

@@ -11,6 +11,7 @@ import '../../shared/identity_transfer/native_community_entry.dart';
import '../../shared/theme/theme.dart';
import '../../shared/utils/string_utils.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/onboarding_step_page.dart';
import '../../shared/widgets/buzz_titled_sheet_layout.dart';
/// Local preparation seam; the production service validates and retains keys.
@@ -23,7 +24,20 @@ Future<bool> showSelfHostedEntry(
BuildContext context, {
required String relay,
required String name,
bool fullScreen = false,
}) async {
if (fullScreen) {
return await Navigator.of(context, rootNavigator: true).push<bool>(
MaterialPageRoute(
builder: (_) => SelfHostedEntrySheet(
relay: relay,
name: name,
fullScreen: true,
),
),
) ??
false;
}
if (defaultTargetPlatform == TargetPlatform.iOS) {
final container = ProviderScope.containerOf(context);
final service = container.read(selfHostedEntryProvider);
@@ -88,9 +102,11 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
super.key,
required this.relay,
required this.name,
this.fullScreen = false,
});
final String relay;
final String name;
final bool fullScreen;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -184,7 +200,7 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
final saved = choices.data?.choices ?? const <DeviceIdentityChoice>[];
final joinContent = Padding(
padding: const EdgeInsets.all(Grid.gutter),
padding: fullScreen ? EdgeInsets.zero : const EdgeInsets.all(Grid.gutter),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -228,9 +244,31 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
button(
onPressed: busy.value
? null
: () {
importFocusReady.value = false;
importing.value = true;
: () async {
if (fullScreen) {
final imported = await Navigator.of(context)
.push<bool>(
MaterialPageRoute(
builder: (importContext) => NsecImportSheet(
fullScreen: true,
onBack: () {
generation.value++;
busy.value = false;
error.value = null;
Navigator.of(importContext).pop(false);
},
onImport: (nsec) =>
connect(nsec: nsec, close: false),
),
),
);
if (imported == true && context.mounted) {
Navigator.of(context).pop(true);
}
} else {
importFocusReady.value = false;
importing.value = true;
}
},
child: const Text('Import a Nostr key'),
),
@@ -256,6 +294,17 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
),
),
);
if (fullScreen) {
return OnboardingStepPage(
title: 'Join community',
subtitle: 'Select an identity to use in this community.',
onBack: () {
generation.value++;
Navigator.of(context).pop(false);
},
content: joinContent,
);
}
return AnimatedSize(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero

View File

@@ -0,0 +1,106 @@
import 'package:flutter/gestures.dart';
import 'community_link_focus.dart';
import '../../shared/widgets/ios_community_link_input.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../../shared/deeplink/community_link_input.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/onboarding_step_page.dart';
part 'add_community_page/add_community_form.dart';
part 'add_community_page/add_community_connection.dart';
part 'add_community_page/pairing_reveal_surface.dart';
/// Fullscreen Add Community presentation; connection operations belong to callers.
class AddCommunityPage extends StatelessWidget {
const AddCommunityPage({
super.key,
required this.onBack,
required this.onCommunityLink,
required this.onPairing,
required this.onScan,
required this.onOpenCommunity,
required this.onSubmitPairing,
this.enteringCommunityLink = false,
this.existingUser = false,
this.scannerProgress = 0,
this.busy = false,
this.canSubmit = true,
this.errorMessage,
this.accountStatus,
this.onInputChanged,
});
final VoidCallback onBack;
final VoidCallback? onCommunityLink;
final VoidCallback? onPairing;
final VoidCallback? onScan;
final ValueChanged<Uri> onOpenCommunity;
final ValueChanged<String> onSubmitPairing;
final bool enteringCommunityLink;
final bool existingUser;
final double scannerProgress;
final bool busy;
final bool canSubmit;
final String? errorMessage;
final String? accountStatus;
final VoidCallback? onInputChanged;
@override
Widget build(BuildContext context) {
final step = enteringCommunityLink
? 'link'
: existingUser
? 'pair'
: 'options';
final page = step == 'options' || step == 'link'
? _AddCommunityConnection(
key: const ValueKey('add-community-connection'),
enteringLink: enteringCommunityLink,
scannerProgress: scannerProgress,
accountStatus: accountStatus,
onBack: onBack,
onCommunityLink: onCommunityLink,
onPairing: onPairing,
onScan: onScan,
onOpenCommunity: onOpenCommunity,
busy: busy,
canSubmit: canSubmit,
errorMessage: errorMessage,
onInputChanged: onInputChanged,
)
: _AddCommunityForm(
key: ValueKey(step),
onBack: onBack,
onSubmitPairing: onSubmitPairing,
busy: busy,
canSubmit: canSubmit,
errorMessage: errorMessage,
onInputChanged: onInputChanged,
);
return AnimatedSwitcher(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 250),
layoutBuilder: (current, previous) => Stack(
fit: StackFit.expand,
children: [
for (final child in previous)
ExcludeFocus(
child: ExcludeSemantics(child: IgnorePointer(child: child)),
),
?current,
],
),
transitionBuilder: (child, animation) => SlideTransition(
position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
),
child: child,
),
child: page,
);
}
}

View File

@@ -0,0 +1,208 @@
part of '../add_community_page.dart';
/// The link choice and Continue share one button throughout the transition.
class _AddCommunityConnection extends HookWidget {
const _AddCommunityConnection({
super.key,
required this.enteringLink,
required this.scannerProgress,
required this.onBack,
required this.onCommunityLink,
required this.onPairing,
required this.onScan,
required this.onOpenCommunity,
required this.busy,
required this.canSubmit,
this.errorMessage,
this.accountStatus,
this.onInputChanged,
});
final bool enteringLink;
final double scannerProgress;
final VoidCallback onBack;
final VoidCallback? onCommunityLink;
final VoidCallback? onPairing;
final VoidCallback? onScan;
final ValueChanged<Uri> onOpenCommunity;
final bool busy;
final bool canSubmit;
final String? errorMessage;
final String? accountStatus;
final VoidCallback? onInputChanged;
@override
Widget build(BuildContext context) {
final controller = useTextEditingController();
final focus = useFocusNode();
final error = useState<String?>(null);
final pairingVisible = useState(false);
void revealPairing() => pairingVisible.value = !pairingVisible.value;
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final ios = defaultTargetPlatform == TargetPlatform.iOS;
final keyboardReady = useCommunityLinkFocus(
context,
enteringLink: enteringLink,
focus: focus,
);
void submit() {
if (busy || !canSubmit) return;
final link = parseCommunityLinkInput(controller.text.trim());
if (link == null) {
error.value = 'Enter a valid community address or invite link.';
return;
}
focus.unfocus();
onOpenCommunity(link);
}
return _PairingRevealSurface(
onReveal: enteringLink || busy || scannerProgress > 0
? null
: revealPairing,
child: TweenAnimationBuilder<double>(
tween: Tween(end: enteringLink && (!ios || keyboardReady) ? 1 : 0),
duration: reducedMotion
? Duration.zero
: Duration(milliseconds: ios ? 200 : 150),
curve: ios ? Curves.easeOutCubic : Curves.easeOutQuad,
builder: (context, progress, _) => OnboardingStepPage(
title: enteringLink ? 'Enter a community link' : 'Add a community',
subtitle: enteringLink
? 'Paste an invite link or community address.'
: accountStatus ?? 'Scan a QR code or enter a community link.',
onBack: onBack,
foregroundProgress: scannerProgress,
keyboardBottomSpacing: Grid.twelve,
onConnectionOptions: enteringLink || busy ? null : revealPairing,
content: ExcludeSemantics(
excluding: !enteringLink,
child: ExcludeFocus(
excluding: !enteringLink,
child: IgnorePointer(
ignoring: !enteringLink,
child: Opacity(
opacity: progress,
child: Transform.translate(
key: const Key('add-community-link-rise'),
offset: Offset(
0,
reducedMotion ? 0 : Grid.xl * (1 - progress),
),
child: ios
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
IosCommunityLinkInput(
controller: controller,
focusNode: focus,
enabled: !busy,
onChanged: (_) {
error.value = null;
onInputChanged?.call();
},
onSubmitted: (_) => submit(),
),
if (error.value ?? errorMessage
case final problem?)
Padding(
padding: const EdgeInsets.only(top: Grid.xs),
child: Text(
problem,
style: TextStyle(
color: context.colors.error,
),
),
),
],
)
: TextField(
key: const Key('community-link-input'),
controller: controller,
focusNode: focus,
enabled: !busy,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.go,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: 'Community link or address',
hintText: 'community.example.com',
errorText: error.value ?? errorMessage,
),
onChanged: (_) {
error.value = null;
onInputChanged?.call();
},
onSubmitted: (_) => submit(),
),
),
),
),
),
),
actions: [
ExcludeSemantics(
excluding: enteringLink,
child: ExcludeFocus(
excluding: enteringLink,
child: IgnorePointer(
ignoring: enteringLink,
child: Opacity(
opacity: 1 - progress,
child: FilledButton(
onPressed: busy ? null : onScan,
child: const Text('Scan a QR code'),
),
),
),
),
),
FilledButton(
key: const Key('add-community-link-action'),
style: FilledButton.styleFrom(
backgroundColor: Color.lerp(
context.colors.surface,
context.colors.primary,
progress,
),
foregroundColor: Color.lerp(
context.colors.primary,
context.colors.onPrimary,
progress,
),
side: BorderSide(
color: context.colors.outline.withValues(alpha: 1 - progress),
),
),
onPressed: busy || (enteringLink && !canSubmit)
? null
: enteringLink
? submit
: onCommunityLink,
child: AnimatedSwitcher(
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 150),
child: Text(
enteringLink
? busy
? 'Connecting…'
: 'Continue'
: 'Enter a community link',
key: ValueKey(enteringLink),
),
),
),
if (pairingVisible.value && !enteringLink)
OutlinedButton(
onPressed: busy ? null : onPairing,
child: const Text('Use a pairing code'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,67 @@
part of '../add_community_page.dart';
class _AddCommunityForm extends HookWidget {
const _AddCommunityForm({
super.key,
required this.onBack,
required this.onSubmitPairing,
required this.busy,
required this.canSubmit,
this.errorMessage,
this.onInputChanged,
});
final VoidCallback onBack;
final ValueChanged<String> onSubmitPairing;
final bool busy;
final bool canSubmit;
final String? errorMessage;
final VoidCallback? onInputChanged;
@override
Widget build(BuildContext context) {
final controller = useTextEditingController();
final error = useState<String?>(null);
void submit() {
if (busy || !canSubmit) return;
final value = controller.text.trim();
if (value.isEmpty) {
error.value = 'Enter a pairing code.';
return;
}
FocusManager.instance.primaryFocus?.unfocus();
onSubmitPairing(value);
}
return OnboardingStepPage(
title: 'Use a pairing code',
subtitle: 'Paste the pairing code from your desktop app.',
onBack: onBack,
content: TextField(
key: const Key('community-pairing-input'),
controller: controller,
enabled: !busy,
autofocus: true,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.go,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: 'Pairing code',
hintText: 'nostrpair://... or buzz://...',
errorText: error.value ?? errorMessage,
),
onChanged: (_) {
error.value = null;
onInputChanged?.call();
},
onSubmitted: (_) => submit(),
),
actions: [
FilledButton(
onPressed: busy || !canSubmit ? null : submit,
child: Text(busy ? 'Connecting…' : 'Connect'),
),
],
);
}
}

View File

@@ -0,0 +1,90 @@
part of '../add_community_page.dart';
/// Observe a developer gesture without delaying Scan's ordinary single tap.
class _PairingRevealSurface extends HookWidget {
const _PairingRevealSurface({required this.child, this.onReveal});
final Widget child;
final VoidCallback? onReveal;
@override
Widget build(BuildContext context) {
final tracker = useMemoized(_PairingRevealTracker.new, [onReveal != null]);
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: onReveal == null ? null : tracker.down,
onPointerMove: onReveal == null ? null : tracker.move,
onPointerCancel: tracker.pointerCancel,
onPointerUp: onReveal == null
? null
: (event) => tracker.up(event, onReveal!),
child: child,
);
}
}
class _PairingRevealTracker {
final _active = <int, PointerDownEvent>{};
final _positions = <Offset>[];
Duration? _start;
Duration? _previousEnd;
Offset? _previousCenter;
bool _invalid = false;
void cancel() {
_invalid = true;
_previousEnd = null;
_previousCenter = null;
}
void down(PointerDownEvent event) {
if (_active.isEmpty) {
_positions.clear();
_start = event.timeStamp;
_invalid = false;
}
_active[event.pointer] = event;
_positions.add(event.position);
if (_positions.length > 2 ||
event.timeStamp - _start! > kDoubleTapTimeout) {
cancel();
}
}
void move(PointerMoveEvent event) {
final start = _active[event.pointer];
if (start != null &&
(event.position - start.position).distance > kTouchSlop) {
cancel();
}
}
void up(PointerUpEvent event, VoidCallback reveal) {
final start = _active.remove(event.pointer);
if (start == null) return;
if (_positions.length != 2 ||
(event.position - start.position).distance > kTouchSlop ||
event.timeStamp - _start! > kDoubleTapTimeout) {
cancel();
}
if (_active.isNotEmpty) return;
if (_invalid) return;
final center = (_positions[0] + _positions[1]) / 2;
final gap = _previousEnd == null ? null : _start! - _previousEnd!;
if (gap != null &&
gap >= kDoubleTapMinTime &&
gap <= kDoubleTapTimeout &&
(center - _previousCenter!).distance <= kDoubleTapSlop) {
_previousEnd = null;
_previousCenter = null;
reveal();
} else {
_previousEnd = event.timeStamp;
_previousCenter = center;
}
}
void pointerCancel(PointerCancelEvent event) {
_active.remove(event.pointer);
cancel();
}
}

View File

@@ -0,0 +1,59 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Start link motion with the native keyboard, with a hardware-keyboard fallback.
bool useCommunityLinkFocus(
BuildContext context, {
required bool enteringLink,
required FocusNode focus,
}) {
final ios = defaultTargetPlatform == TargetPlatform.iOS;
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final keyboardReady = useState(false);
final enteringLinkRef = useRef(enteringLink)..value = enteringLink;
useEffect(() {
const channel = MethodChannel('buzz/community_keyboard');
Timer? fallback;
var disposed = false;
void beginOpening() {
if (disposed || !enteringLinkRef.value || keyboardReady.value) return;
fallback?.cancel();
keyboardReady.value = true;
}
if (ios && enteringLink) {
channel.setMethodCallHandler((call) async {
if (call.method != 'willShow') return;
beginOpening();
});
}
if (enteringLink) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (disposed || !context.mounted || !enteringLinkRef.value) return;
focus.requestFocus();
if (!ios ||
reducedMotion ||
MediaQuery.viewInsetsOf(context).bottom > 0) {
beginOpening();
} else {
// Hardware keyboards and older hosts may send no show notification.
fallback = Timer(const Duration(milliseconds: 350), beginOpening);
}
});
} else {
focus.unfocus();
keyboardReady.value = false;
}
return () {
disposed = true;
fallback?.cancel();
if (ios && enteringLink) channel.setMethodCallHandler(null);
};
}, [enteringLink, ios, reducedMotion]);
return keyboardReady.value;
}

View File

@@ -1,3 +1,5 @@
import 'community_link_focus.dart';
import '../../shared/widgets/ios_community_link_input.dart';
import 'dart:async';
import '../../shared/hosted/hosted_flow_provider.dart';
import '../../shared/hosted/hosted_flow.dart';
@@ -8,8 +10,6 @@ import 'dart:math' show sqrt2;
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'
show CupertinoColors, CupertinoTextField;
import 'package:flutter/foundation.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import 'package:flutter/services.dart';
@@ -31,6 +31,8 @@ import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
import 'pairing_page/onboarding_wordmark.dart';
import 'pairing_provider.dart';
import 'add_community_page.dart';
import '../../shared/widgets/onboarding_step_page.dart';
import 'pairing_qr_scanner.dart';
part 'pairing_page/onboarding_background.dart';
@@ -221,7 +223,7 @@ class PairingPage extends HookConsumerWidget {
: IconButton(
icon: const Icon(BuzzIcons.arrowLeft),
tooltip: 'Back',
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.of(context).maybePop(),
),
title: identityRecoveryOnly
? Text(
@@ -239,6 +241,25 @@ class PairingPage extends HookConsumerWidget {
pairingState.status == PairingStatus.storing ||
pairingState.status == PairingStatus.success;
final destinationRelay = pairingState.destinationRelayUrl;
final verificationView = _SasVerificationView(
key: ValueKey(pairingState.sasCode),
fullScreen: addingCommunity && connectionLanding,
sasCode: pairingState.sasCode ?? '------',
verifyDesktopCode: pairingState.requiresDesktopCode
? ref.read(pairingProvider.notifier).verifyDesktopCode
: null,
confirmed: pairingState.userConfirmedSas,
sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop,
biometricLabel: biometricProtectionLabel(
defaultTargetPlatform,
enrolledBiometrics.value ?? const [],
),
errorMessage: pairingState.errorMessage,
onProtectionChanged: (value) =>
ref.read(pairingProvider.notifier).setProtectSensitiveActions(value),
onConfirm: () => ref.read(pairingProvider.notifier).confirmSas(),
onDeny: () => ref.read(pairingProvider.notifier).denySas(),
);
final pairingScaffold = showLoading
? CommunityLoadingSurface(
key: const Key('pairing-community-loading'),
@@ -248,42 +269,70 @@ class PairingPage extends HookConsumerWidget {
relayUrl: destinationRelay,
)
: isVerifyingSas
? AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('pairing-sas-system-overlay'),
value: onboardingSystemOverlayStyle,
child: _OnboardingBackground(
child: Scaffold(
resizeToAvoidBottomInset:
hostedFlow?.stage != HostedFlowStage.profile,
backgroundColor: Colors.transparent,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
child: _SasVerificationView(
key: ValueKey(pairingState.sasCode),
sasCode: pairingState.sasCode ?? '------',
verifyDesktopCode: pairingState.requiresDesktopCode
? ref.read(pairingProvider.notifier).verifyDesktopCode
: null,
confirmed: pairingState.userConfirmedSas,
sendsIdentityToDesktop:
pairingState.sendsIdentityToDesktop,
biometricLabel: biometricProtectionLabel(
defaultTargetPlatform,
enrolledBiometrics.value ?? const [],
? addingCommunity && connectionLanding
? verificationView
: AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('pairing-sas-system-overlay'),
value: onboardingSystemOverlayStyle,
child: _OnboardingBackground(
child: Scaffold(
resizeToAvoidBottomInset:
hostedFlow?.stage != HostedFlowStage.profile,
backgroundColor: Colors.transparent,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.sm,
),
child: verificationView,
),
),
errorMessage: pairingState.errorMessage,
onProtectionChanged: (value) => ref
.read(pairingProvider.notifier)
.setProtectSensitiveActions(value),
onConfirm: () =>
ref.read(pairingProvider.notifier).confirmSas(),
onDeny: () =>
ref.read(pairingProvider.notifier).denySas(),
),
),
),
),
)
: addingCommunity && connectionLanding
? ValueListenableBuilder<double>(
valueListenable: islandScannerProgress,
builder: (context, progress, _) => AddCommunityPage(
scannerProgress: progress,
enteringCommunityLink: enteringCommunityLink,
existingUser: existingUser,
onBack: onWelcomeBack ?? () => Navigator.of(context).pop(),
onCommunityLink: onCommunityLink,
onPairing: onAlreadyUseBuzz,
onScan: openScanner,
onOpenCommunity: (uri) {
ref.read(hostedInlineEntryProvider.notifier).setActive(true);
ref.read(pendingDeepLinkProvider.notifier).open(uri);
},
onSubmitPairing: (code) => unawaited(handleScannerResult(code)),
accountStatus: accountStatus,
busy:
isBusy ||
builderlabBusy ||
(hostedFlow != null &&
!hostedFlow.accountOnly &&
(hostedFlow.stage == HostedFlowStage.working ||
hostedFlow.stage == HostedFlowStage.signIn)),
canSubmit:
hostedFlow == null ||
hostedFlow.accountOnly ||
hostedFlow.problem == null ||
hostedFlow.canRetry,
errorMessage:
pairingState.errorMessage ??
(hostedFlow != null &&
!hostedFlow.accountOnly &&
hostedFlow.problem != null
? hostedFlowProblemMessage(hostedFlow.problem)
: null),
onInputChanged: () {
if (hostedFlow != null &&
!hostedFlow.accountOnly &&
hostedFlow.problem != null) {
unawaited(hostedFlow.leave());
}
},
),
)
: AnnotatedRegion<SystemUiOverlayStyle>(

View File

@@ -84,40 +84,25 @@ class _CommunityLinkForm extends HookConsumerWidget {
if (fullPage && showHeading) ...[
Text(
'Enter a community link',
style: context.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
style: OnboardingTypography.title.copyWith(
color: context.colors.onSurface,
),
),
const SizedBox(height: Grid.xs),
Text(
'Paste an invite link or community address.',
style: context.textTheme.titleLarge?.copyWith(
style: OnboardingTypography.subtitle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.lg),
],
if (iosInput) ...[
CupertinoTextField(
key: const Key('community-link-input'),
IosCommunityLinkInput(
controller: controller,
focusNode: focusNode,
autofocus: fullPage && autofocus,
enabled: !busy,
placeholder: 'Community link or address',
placeholderStyle: context.textTheme.titleLarge?.copyWith(
color: CupertinoColors.placeholderText.resolveFrom(context),
),
keyboardType: TextInputType.url,
textInputAction: TextInputAction.go,
autocorrect: false,
enableSuggestions: false,
padding: const EdgeInsets.symmetric(vertical: Grid.xs),
style: context.textTheme.titleLarge?.copyWith(
color: context.colors.onSurface,
),
cursorColor: context.colors.primary,
decoration: null,
onChanged: changed,
onSubmitted: (_) => submit(),
),

View File

@@ -23,7 +23,6 @@ class _OnboardingConnectionView extends HookWidget {
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final ios = defaultTargetPlatform == TargetPlatform.iOS;
final keyboardReady = useState(false);
final staggerEntrance = enteringLink && !ios;
// Match the iOS return motion, starting after keyboard preparation.
final duration = reducedMotion
@@ -35,46 +34,11 @@ class _OnboardingConnectionView extends HookWidget {
// A new viewport (for example rotation) gets a fresh measurement.
final viewport = MediaQuery.sizeOf(context);
final restingHeight = useMemoized(() => <double?>[null], [viewport]);
final enteringLinkRef = useRef(enteringLink)..value = enteringLink;
useEffect(() {
const channel = MethodChannel('buzz/community_keyboard');
Timer? fallback;
var disposed = false;
void beginOpening() {
if (disposed || !enteringLinkRef.value || keyboardReady.value) return;
fallback?.cancel();
keyboardReady.value = true;
}
if (ios && enteringLink) {
channel.setMethodCallHandler((call) async {
if (call.method != 'willShow') return;
beginOpening();
});
}
if (enteringLink) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (disposed || !context.mounted || !enteringLinkRef.value) return;
focus.requestFocus();
if (!ios ||
reducedMotion ||
MediaQuery.viewInsetsOf(context).bottom > 0) {
beginOpening();
} else {
// Hardware keyboards and older hosts may send no show notification.
fallback = Timer(const Duration(milliseconds: 350), beginOpening);
}
});
} else {
focus.unfocus();
keyboardReady.value = false;
}
return () {
disposed = true;
fallback?.cancel();
if (ios && enteringLink) channel.setMethodCallHandler(null);
};
}, [enteringLink, ios, reducedMotion]);
final keyboardReady = useCommunityLinkFocus(
context,
enteringLink: enteringLink,
focus: focus,
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
@@ -105,7 +69,7 @@ class _OnboardingConnectionView extends HookWidget {
}
return TweenAnimationBuilder<double>(
tween: Tween(
end: enteringLink && (!ios || keyboardReady.value) ? 1 : 0,
end: enteringLink && (!ios || keyboardReady) ? 1 : 0,
),
duration: duration,
curve: ios
@@ -355,11 +319,16 @@ class _ConnectionHeading extends StatelessWidget {
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: context.textTheme.headlineSmall),
Text(
title,
style: OnboardingTypography.title.copyWith(
color: context.colors.onSurface,
),
),
const SizedBox(height: Grid.xs),
Text(
subtitle,
style: context.textTheme.bodyLarge?.copyWith(
style: OnboardingTypography.subtitle.copyWith(
color: context.colors.onSurfaceVariant,
),
),

View File

@@ -3,6 +3,7 @@ part of '../pairing_page.dart';
/// SAS verification screen shown during NIP-AB pairing.
class _SasVerificationView extends HookConsumerWidget {
final String sasCode;
final bool fullScreen;
final Future<bool> Function(String)? verifyDesktopCode;
final bool confirmed;
final bool sendsIdentityToDesktop;
@@ -14,6 +15,7 @@ class _SasVerificationView extends HookConsumerWidget {
const _SasVerificationView({
super.key,
this.fullScreen = false,
required this.sasCode,
this.verifyDesktopCode,
required this.confirmed,
@@ -124,36 +126,37 @@ class _SasVerificationView extends HookConsumerWidget {
color: context._onboardingInk,
),
const SizedBox(height: Grid.sm),
Text(
'Protect your identity',
textAlign: TextAlign.center,
style: context.textTheme.headlineSmall?.copyWith(
color: context._onboardingInk,
fontWeight: FontWeight.w600,
if (!fullScreen)
Text(
'Protect your identity',
textAlign: TextAlign.center,
style: OnboardingTypography.title.copyWith(
color: context._onboardingInk,
),
),
),
const SizedBox(height: Grid.xxs),
Text(
'$biometricLabel to confirm it’s you before sending your Buzz identity to another device.',
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context._onboardingMutedInk,
const SizedBox(height: Grid.xs),
if (!fullScreen)
Text(
'$biometricLabel to confirm it’s you before sending your Buzz identity to another device.',
textAlign: TextAlign.center,
style: OnboardingTypography.subtitle.copyWith(
color: context._onboardingMutedInk,
),
),
),
] else if (codeAccepted.value) ...[
Icon(BuzzIcons.shieldCheck, size: 64, color: context._onboardingInk),
const SizedBox(height: Grid.sm),
Text('Code confirmed', style: context.textTheme.headlineSmall),
if (!fullScreen)
Text('Code confirmed', style: OnboardingTypography.title),
] else ...[
Text(
'Enter pairing code',
textAlign: TextAlign.center,
style: context.textTheme.headlineSmall?.copyWith(
color: context._onboardingInk,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
if (!fullScreen)
Text(
'Enter pairing code',
textAlign: TextAlign.center,
style: OnboardingTypography.title.copyWith(
color: context._onboardingInk,
),
),
),
const SizedBox(height: Grid.md),
_PairingErrorShake(
revision: shakeRevision.value,
@@ -248,6 +251,23 @@ class _SasVerificationView extends HookConsumerWidget {
),
);
if (fullScreen) {
return OnboardingStepPage(
title: showProtection
? 'Protect your identity'
: codeAccepted.value
? 'Code confirmed'
: 'Enter pairing code',
subtitle: showProtection
? '$biometricLabel to confirm it’s you before sending your Buzz identity to another device.'
: codeAccepted.value
? null
: 'Enter the six-digit code shown on your desktop.',
onBack: onDeny,
content: verificationContent,
actions: [verificationActions],
);
}
return Column(
children: [
Expanded(

View File

@@ -10,6 +10,7 @@ import 'features/onboarding/hosted_connect_page.dart';
import 'features/onboarding/self_hosted_entry_sheet.dart';
import 'shared/auth/auth.dart';
import 'shared/theme/grid.dart';
import 'shared/theme/theme_extensions.dart';
import 'shared/deeplink/deep_link.dart';
import 'shared/hosted/hosted_community_connection.dart';
import 'shared/hosted/hosted_flow.dart';
@@ -59,13 +60,7 @@ class HostedAppGate extends HookConsumerWidget {
(flow.accountOnly &&
flow.stage == HostedFlowStage.working &&
flow.account.account == null));
final inlineAccountStatus =
flow != null &&
(!authenticated || inlineEntry) &&
flow.accountOnly &&
(flow.stage == HostedFlowStage.working ||
(flow.stage == HostedFlowStage.blocked &&
!flow.canChangeCommunities));
final fullScreenSetup = visible && !openingSignIn;
return DeepLinkDispatcher(
dispatchMessageLinks: authenticated && !visible,
onboardingHandler: (routeContext, link) async {
@@ -98,6 +93,7 @@ class HostedAppGate extends HookConsumerWidget {
routeContext,
relay: link.relayUrl,
name: link.name,
fullScreen: inlineEntry,
);
if (joined && context.mounted) {
ref.read(communityEntryCompletionProvider.notifier).complete();
@@ -134,7 +130,14 @@ class HostedAppGate extends HookConsumerWidget {
child: Stack(
fit: StackFit.expand,
children: [
child,
ExcludeFocus(
excluding: fullScreenSetup,
child: ExcludeSemantics(
excluding: fullScreenSetup,
child: Offstage(offstage: fullScreenSetup, child: child),
),
),
if (fullScreenSetup) ColoredBox(color: context.colors.surface),
if (flow != null) ...[
if (authenticated && !visible) HostedReconnectBanner(flow: flow),
if (flow.stage == HostedFlowStage.signIn &&
@@ -167,21 +170,11 @@ class HostedAppGate extends HookConsumerWidget {
),
),
),
if (flow.stage == HostedFlowStage.profile)
ProfileSetupPage(flow: flow),
if (visible &&
flow.stage != HostedFlowStage.profile &&
!openingSignIn &&
!inlineAccountStatus &&
(!inlineEntry ||
flow.stage == HostedFlowStage.chooseIdentity ||
flow.stage == HostedFlowStage.setupIdentity ||
flow.stage == HostedFlowStage.chooseCommunities ||
flow.stage == HostedFlowStage.communityConflict ||
flow.canChangeCommunities))
if (fullScreenSetup)
HostedConnectPage(
flow: flow,
onClose: () => unawaited(flow.leave()),
profileBuilder: (_) => ProfileSetupPage(flow: flow),
),
],
],

View File

@@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'features/pairing/pairing_page.dart';
import 'features/onboarding/hosted_connect_sheet.dart';
import 'features/pairing/pairing_provider.dart';
import 'shared/hosted/hosted_flow_provider.dart';
import 'shared/hosted/hosted_flow.dart';
@@ -14,8 +13,15 @@ import 'shared/hosted/hosted_flow_problem_message.dart';
/// Navigation over the existing branded welcome surface.
class OnboardingAppEntry extends HookConsumerWidget {
const OnboardingAppEntry({super.key, this.addingCommunity = false});
const OnboardingAppEntry({
super.key,
this.addingCommunity = false,
this.restoreAccount = true,
this.onExit,
});
final bool addingCommunity;
final bool restoreAccount;
final VoidCallback? onExit;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -27,7 +33,7 @@ class OnboardingAppEntry extends HookConsumerWidget {
: null;
useListenable(flow);
useEffect(() {
if (flow == null) return null;
if (flow == null || !restoreAccount) return null;
Future<void> restore() async {
await flow.initialize();
if (context.mounted) await flow.signInAccount(openBrowser: false);
@@ -63,6 +69,7 @@ class OnboardingAppEntry extends HookConsumerWidget {
scheduleMicrotask(() {
presentation.setActive(false);
if (addingCommunity &&
onExit == null &&
flow != null &&
flow.stage != HostedFlowStage.complete) {
unawaited(flow.leave());
@@ -70,20 +77,6 @@ class OnboardingAppEntry extends HookConsumerWidget {
});
};
}, [flow]);
final inlineSetup =
flow != null &&
!flow.accountOnly &&
flow.stage != HostedFlowStage.signIn &&
!(flow.stage == HostedFlowStage.working &&
flow.account.account == null) &&
flow.stage != HostedFlowStage.idle &&
flow.stage != HostedFlowStage.complete &&
flow.stage != HostedFlowStage.profile &&
flow.stage != HostedFlowStage.chooseIdentity &&
flow.stage != HostedFlowStage.setupIdentity &&
flow.stage != HostedFlowStage.chooseCommunities &&
flow.stage != HostedFlowStage.communityConflict &&
!flow.canChangeCommunities;
final signingIn = flow?.stage == HostedFlowStage.signIn;
final busy =
flow != null &&
@@ -103,7 +96,25 @@ class OnboardingAppEntry extends HookConsumerWidget {
: connected
? 'Connected to Builderlab.'
: null;
final handlingBack = useRef(false);
void back() {
// Nested page and entry PopScopes receive the same system Back event.
if (handlingBack.value) return;
handlingBack.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
handlingBack.value = false;
});
if (!existingUser.value &&
!enteringCommunityLink.value &&
!creating.value &&
(onExit != null || addingCommunity)) {
if (onExit != null) {
onExit!();
} else {
Navigator.of(context).pop();
}
return;
}
unawaited(flow?.leave());
existingUser.value = false;
creating.value = false;
@@ -114,16 +125,23 @@ class OnboardingAppEntry extends HookConsumerWidget {
return PopScope(
canPop:
onExit == null &&
!existingUser.value &&
!enteringCommunityLink.value &&
!creating.value &&
!inlineSetup,
!creating.value,
onPopInvokedWithResult: (didPop, _) {
if (!didPop &&
onExit != null &&
!existingUser.value &&
!enteringCommunityLink.value &&
!creating.value) {
back();
return;
}
if (!didPop &&
(existingUser.value ||
creating.value ||
enteringCommunityLink.value ||
inlineSetup)) {
enteringCommunityLink.value)) {
back();
}
},
@@ -155,15 +173,6 @@ class OnboardingAppEntry extends HookConsumerWidget {
accountStatus: status,
builderlabBusy: busy,
signInPending: signingIn || busy,
setupContent: flow != null && inlineSetup
? Column(
mainAxisSize: MainAxisSize.min,
children: [
HostedConnectSheet(flow: flow),
TextButton(onPressed: back, child: const Text('Leave setup')),
],
)
: null,
builderlabLabel: signingIn
? 'Cancel sign-in'
: busy

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
const communityGridAvatarSize = 88.0;
/// Intrinsic-height rows keep two columns without clipping scaled labels.
class CommunityGrid extends StatelessWidget {
const CommunityGrid({super.key, required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) => Column(
key: const Key('community-switcher-options'),
mainAxisSize: MainAxisSize.min,
children: [
for (var index = 0; index < children.length; index += 2)
Padding(
padding: const EdgeInsets.only(bottom: Grid.md),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: children[index]),
const SizedBox(width: Grid.xs),
Expanded(
child: index + 1 < children.length
? children[index + 1]
: const SizedBox.shrink(),
),
],
),
),
],
);
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
import '../theme/buzz_icons.dart';
import 'community_grid.dart';
/// Opens the Add Community flow from a community grid.
class CommunityGridAdd extends StatelessWidget {
const CommunityGridAdd({super.key, required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Semantics(
button: true,
child: InkWell(
key: const Key('community-switcher-add'),
borderRadius: BorderRadius.circular(Radii.card),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
children: [
ClipRSuperellipse(
borderRadius: BorderRadius.circular(
Radii.card * communityGridAvatarSize / 40,
),
child: Container(
width: communityGridAvatarSize,
height: communityGridAvatarSize,
color: context.controlSurface.withValues(alpha: 0.5),
child: Icon(
BuzzIcons.plus,
size: 32,
color: context.colors.onSurfaceVariant,
),
),
),
const SizedBox(height: Grid.xs),
Text(
'Add Community',
textAlign: TextAlign.center,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}

View File

@@ -0,0 +1,92 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
import 'community_avatar.dart';
import 'community_grid.dart';
/// A community tile shared by onboarding and the switcher.
class CommunityGridOption extends HookWidget {
const CommunityGridOption({
super.key,
required this.id,
required this.name,
required this.relayUrl,
required this.onSelect,
this.isActive = false,
this.hidden = false,
this.enabled = true,
this.badge,
});
final String id;
final String name;
final String relayUrl;
final Future<void> Function(Rect) onSelect;
final bool isActive;
final bool hidden;
final bool enabled;
final Widget? badge;
@override
Widget build(BuildContext context) {
final avatarKey = useMemoized(GlobalKey.new);
return Semantics(
selected: isActive,
button: enabled,
child: InkWell(
key: Key('community-switcher-row-$id'),
borderRadius: BorderRadius.circular(Radii.card),
onTap: !enabled
? null
: () {
final box = avatarKey.currentContext?.findRenderObject();
if (box is RenderBox) {
unawaited(
onSelect(box.localToGlobal(Offset.zero) & box.size),
);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Opacity(
opacity: hidden ? 0 : 1,
child: SizedBox(
key: avatarKey,
child: ExcludeSemantics(
child: CommunityAvatar(
key: Key('community-switcher-avatar-$id'),
name: name,
relayUrl: relayUrl,
size: communityGridAvatarSize,
),
),
),
),
if (badge != null)
Positioned(
right: -Grid.twelve,
bottom: -Grid.twelve,
child: badge!,
),
],
),
const SizedBox(height: Grid.xs),
Text(
name,
textAlign: TextAlign.center,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
}

View File

@@ -77,6 +77,7 @@ class HostedFlow extends ChangeNotifier {
required this.store,
required this.identity,
required this.enterCommunity,
this.openLandingCommunity,
Future<bool> Function(Uri)? openWeb,
}) : _openWeb =
openWeb ??
@@ -86,6 +87,7 @@ class HostedFlow extends ChangeNotifier {
final AccountIdentityStore store;
final HostedIdentityService identity;
final HostedCommunityAdmission enterCommunity;
final HostedCommunityAdmission? openLandingCommunity;
bool get canRetry =>
problem == null ||
problem == HostedFlowProblem.retry ||
@@ -103,15 +105,23 @@ class HostedFlow extends ChangeNotifier {
SensitiveActionPolicy? pairedSensitiveActionPolicy;
List<AddCommunityDeepLink> communities = const [];
Set<String> selectedRelays = const {};
String? landingRelay;
final Set<String> _addedRelays = {};
bool _addingCommunities = false;
Completer<bool>? _pendingAdd;
String? _batchSubject;
String? _conflictingRelay;
String? _failedRelay;
bool get canSkipFailedCommunity =>
canChangeCommunities &&
stage == HostedFlowStage.blocked &&
_failedRelay != null;
bool get canChangeCommunities =>
accountOnly &&
account.account?.subject == _batchSubject &&
(stage == HostedFlowStage.blocked ||
stage == HostedFlowStage.communityConflict) &&
stage == HostedFlowStage.communityConflict ||
(stage == HostedFlowStage.working && _addingCommunities)) &&
communities.isNotEmpty;
String? addingCommunityName;
int get addedCommunityCount => _addedRelays.length;
@@ -122,6 +132,7 @@ class HostedFlow extends ChangeNotifier {
int _generation = 0;
int _nextGeneration() {
_failedRelay = null;
_cancelPolicy();
_cancelProfile();
return ++_generation;
@@ -633,18 +644,33 @@ class HostedFlow extends ChangeNotifier {
_set(HostedFlowStage.complete);
}
void selectCommunity(String relay, bool selected) {
void chooseLandingCommunity(String relay) {
if (stage != HostedFlowStage.chooseCommunities ||
!communities.any((item) => item.relayUrl == relay)) {
return;
}
selectedRelays = Set.unmodifiable({
...selectedRelays.where((item) => item != relay),
if (selected) relay,
});
selectedRelays = Set.unmodifiable({...selectedRelays, relay});
landingRelay = relay;
notifyListeners();
}
/// Restore existing memberships before presenting a separate Add route.
Future<bool> prepareToAddCommunity() {
if (_pendingAdd != null || stage != HostedFlowStage.chooseCommunities) {
return Future.value(false);
}
if (selectedRelays.isEmpty) return Future.value(true);
final pending = _pendingAdd = Completer<bool>();
unawaited(addSelectedCommunities());
return pending.future;
}
void _finishPreparingAdd(bool ready) {
final pending = _pendingAdd;
_pendingAdd = null;
pending?.complete(ready);
}
Future<void> addSelectedCommunities() {
if (!accountOnly ||
stage != HostedFlowStage.chooseCommunities ||
@@ -657,19 +683,29 @@ class HostedFlow extends ChangeNotifier {
return _enqueue(() => _continue(generation));
}
/// Keep completed additions and let the user omit a failing target.
/// Keep completed additions while allowing a different landing choice.
void backToCommunityChoices() {
if (!canChangeCommunities) return;
_finishPreparingAdd(false);
_nextGeneration();
communities = List.unmodifiable(
communities.where((item) => !_addedRelays.contains(item.relayUrl)),
);
selectedRelays = Set.unmodifiable(selectedRelays.difference(_addedRelays));
_conflictingRelay = null;
addingCommunityName = null;
_set(HostedFlowStage.chooseCommunities);
}
/// Omit only the failed membership for this restore attempt.
Future<void> skipFailedCommunity() {
if (!canSkipFailedCommunity) return Future.value();
final relay = _failedRelay!;
selectedRelays = Set.unmodifiable(
selectedRelays.where((item) => item != relay),
);
_failedRelay = null;
final generation = _nextGeneration();
_set(HostedFlowStage.working);
return _enqueue(() => _continue(generation));
}
Future<void> skipConflictingCommunity() {
final relay = _conflictingRelay;
if (stage != HostedFlowStage.communityConflict || relay == null) {
@@ -702,11 +738,17 @@ class HostedFlow extends ChangeNotifier {
await leave(noCommunities: true);
return;
}
// Direct account entry imports every joined community. The existing
// switcher handles choosing which community to enter after admission.
_addingCommunities = true;
landingRelay = listed.first.relayUrl;
// Choose where to land; every membership is restored.
_set(HostedFlowStage.chooseCommunities);
return;
}
for (final community in communities) {
_failedRelay = null;
final ordered = [
...communities.where((item) => item.relayUrl == landingRelay),
...communities.where((item) => item.relayUrl != landingRelay),
];
for (final community in ordered) {
if (!current()) return;
if (!selectedRelays.contains(community.relayUrl) ||
_addedRelays.contains(community.relayUrl)) {
@@ -721,12 +763,20 @@ class HostedFlow extends ChangeNotifier {
_conflictingRelay = community.relayUrl;
_set(HostedFlowStage.communityConflict);
return;
} catch (_) {
if (current()) _failedRelay = community.relayUrl;
rethrow;
}
if (!current()) return;
_addedRelays.add(community.relayUrl);
}
if (!current()) return;
addingCommunityName = null;
if (_pendingAdd != null) {
_set(HostedFlowStage.chooseCommunities);
_finishPreparingAdd(true);
return;
}
if (_addedRelays.isEmpty) {
communities = List.unmodifiable(
communities.where((item) => selectedRelays.contains(item.relayUrl)),
@@ -734,6 +784,19 @@ class HostedFlow extends ChangeNotifier {
await leave(noCommunities: true);
return;
}
final destination = _addedRelays.contains(landingRelay)
? landingRelay!
: _addedRelays.first;
await openLandingCommunity?.call(
AddCommunityDeepLink(
relayUrl: destination,
name: Uri.parse(destination).host,
),
subject,
pubkey,
current,
);
if (!current()) return;
await browser.clearIntent();
if (current()) _set(HostedFlowStage.complete);
}
@@ -835,9 +898,12 @@ class HostedFlow extends ChangeNotifier {
bool noCommunitiesToAdd = false;
void _clearCommunityBatch() {
_finishPreparingAdd(false);
noCommunitiesToAdd = false;
communities = const [];
selectedRelays = const {};
landingRelay = null;
_failedRelay = null;
_addedRelays.clear();
_batchSubject = null;
_conflictingRelay = null;
@@ -909,6 +975,7 @@ class HostedFlow extends ChangeNotifier {
@override
void dispose() {
_disposed = true;
_finishPreparingAdd(false);
_cancelPolicy();
_cancelProfile();
_nextGeneration();

View File

@@ -5,7 +5,6 @@ import '../../features/profile/profile_provider.dart';
import '../relay/relay_provider.dart';
import '../relay/relay_session.dart';
import '../auth/auth.dart';
import '../community/paired_community_landing.dart';
import '../deeplink/deep_link.dart';
import 'account_identity_store.dart';
import 'browser_auth.dart';
@@ -69,6 +68,25 @@ HostedFlow createHostedFlow(
browser: browser,
store: store,
identity: HostedIdentityService(account: account, store: store),
openLandingCommunity: (link, subject, pubkey, isCurrent) async {
bool current() => isCurrent() && account.account?.subject == subject;
final storage = ref.read(communityStorageProvider);
final saved = await storage.loadAll();
if (!current()) return;
final destination = saved
.where(
(item) =>
relayIdentity(item.relayUrl) == relayIdentity(link.relayUrl!) &&
item.pubkey == pubkey,
)
.firstOrNull;
if (destination == null) throw AccountFailure.unavailable;
final active = await storage.loadActiveId();
if (!current() || active == destination.id) return;
await ref
.read(authProvider.notifier)
.authenticateWithCommunity(destination, shouldContinue: current);
},
enterCommunity: (link, subject, pubkey, isCurrent) async {
await connectHostedCommunity(
link: link,
@@ -107,8 +125,6 @@ HostedFlow createHostedFlow(
void onCompleted() {
if (flow.reconnecting && flow.stage == HostedFlowStage.complete) {
unawaited(ref.read(relaySessionProvider.notifier).reconnect());
} else if (flow.accountOnly && flow.stage == HostedFlowStage.complete) {
ref.read(communityGridLandingProvider.notifier).request();
}
}

View File

@@ -7,6 +7,7 @@ import '../hosted/account_identity_store.dart';
import '../theme/theme.dart';
import '../utils/string_utils.dart';
import '../widgets/modal_presentation.dart';
import '../widgets/onboarding_step_page.dart';
import 'nsec_transfer.dart';
/// Lets the user inspect the public identity before explicitly importing it.
@@ -33,6 +34,8 @@ class NsecImportSheet extends HookWidget {
this.pickFile,
this.mismatchMessage,
this.autofocus = true,
this.fullScreen = false,
this.onBack,
});
final Future<void> Function(String nsec) onImport;
@@ -41,6 +44,8 @@ class NsecImportSheet extends HookWidget {
/// Allows a containing sheet to finish expanding before opening the keyboard.
final bool autofocus;
final bool fullScreen;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
@@ -58,22 +63,23 @@ class NsecImportSheet extends HookWidget {
final publicKey = useState<String?>(null);
final error = useState<String?>(null);
final busy = useState(false);
final cancelled = useRef(false);
Future<void> chooseFile() async {
busy.value = true;
error.value = null;
try {
final text = await (pickFile ?? NsecTransfer.pickFile)();
if (!context.mounted || text == null) return;
if (!context.mounted || cancelled.value || text == null) return;
final preview = NsecTransfer.preview(text);
input.text = text.trim();
publicKey.value = preview;
} catch (_) {
if (context.mounted) {
if (context.mounted && !cancelled.value) {
error.value = 'Choose a text file containing one valid nsec.';
}
} finally {
if (context.mounted) busy.value = false;
if (context.mounted && !cancelled.value) busy.value = false;
}
}
@@ -87,11 +93,11 @@ class NsecImportSheet extends HookWidget {
}
busy.value = true;
await onImport(input.text.trim());
if (!context.mounted) return;
if (!context.mounted || cancelled.value) return;
input.clear();
Navigator.of(context).pop(true);
} catch (failure) {
if (!context.mounted) return;
if (!context.mounted || cancelled.value) return;
error.value = switch (failure) {
AccountIdentityFailure.mismatch =>
mismatchMessage ??
@@ -104,10 +110,103 @@ class NsecImportSheet extends HookWidget {
'Could not finish importing this identity. Try again with the same key.',
};
} finally {
if (context.mounted) busy.value = false;
if (context.mounted && !cancelled.value) busy.value = false;
}
}
final children = <Widget>[
if (defaultTargetPlatform == TargetPlatform.iOS)
CupertinoTextField(
controller: input,
placeholder: 'Private key (nsec)',
padding: const EdgeInsets.all(Grid.gutter),
decoration: BoxDecoration(
color: CupertinoColors.secondarySystemGroupedBackground.resolveFrom(
context,
),
borderRadius: BorderRadius.circular(Radii.container),
),
focusNode: focus,
maxLength: 1024,
enabled: !busy.value,
obscureText: true,
autocorrect: false,
enableSuggestions: false,
enableIMEPersonalizedLearning: false,
onChanged: (_) {
publicKey.value = null;
error.value = null;
},
)
else
TextField(
controller: input,
maxLength: 1024,
enabled: !busy.value,
obscureText: true,
autocorrect: false,
enableSuggestions: false,
enableIMEPersonalizedLearning: false,
decoration: const InputDecoration(labelText: 'Private key (nsec)'),
onChanged: (_) {
publicKey.value = null;
error.value = null;
},
),
const SizedBox(height: Grid.xs),
OutlinedButton(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: const StadiumBorder(),
),
onPressed: busy.value ? null : chooseFile,
child: const Text('Choose nsec file'),
),
if (publicKey.value != null) ...[
const Text('Confirm this is the public identity you want to use:'),
const SizedBox(height: Grid.xs),
SelectableText(fullNpub(publicKey.value!)!),
const SizedBox(height: Grid.sm),
],
if (error.value != null)
Text(error.value!, style: TextStyle(color: context.colors.error)),
const SizedBox(height: Grid.xs),
FilledButton(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: const StadiumBorder(),
),
onPressed: busy.value ? null : submit,
child: Text(
busy.value
? 'Importing…'
: publicKey.value == null
? 'Preview identity'
: 'Import this identity',
),
),
];
if (fullScreen) {
return OnboardingStepPage(
title: 'Import identity',
subtitle:
'Bring your Buzz identity from another device. Check the public identity before continuing.',
onBack: () {
if (cancelled.value || (onBack == null && busy.value)) return;
cancelled.value = true;
if (onBack != null) {
onBack!();
} else {
Navigator.of(context).pop(false);
}
},
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children.sublist(0, children.length - 1),
),
actions: [children.last],
);
}
return SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
@@ -119,81 +218,7 @@ class NsecImportSheet extends HookWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (defaultTargetPlatform == TargetPlatform.iOS)
CupertinoTextField(
controller: input,
placeholder: 'Private key (nsec)',
padding: const EdgeInsets.all(Grid.gutter),
decoration: BoxDecoration(
color: CupertinoColors.secondarySystemGroupedBackground
.resolveFrom(context),
borderRadius: BorderRadius.circular(Radii.container),
),
focusNode: focus,
maxLength: 1024,
enabled: !busy.value,
obscureText: true,
autocorrect: false,
enableSuggestions: false,
enableIMEPersonalizedLearning: false,
onChanged: (_) {
publicKey.value = null;
error.value = null;
},
)
else
TextField(
controller: input,
maxLength: 1024,
enabled: !busy.value,
obscureText: true,
autocorrect: false,
enableSuggestions: false,
enableIMEPersonalizedLearning: false,
decoration: const InputDecoration(
labelText: 'Private key (nsec)',
),
onChanged: (_) {
publicKey.value = null;
error.value = null;
},
),
const SizedBox(height: Grid.xs),
OutlinedButton(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: const StadiumBorder(),
),
onPressed: busy.value ? null : chooseFile,
child: const Text('Choose nsec file'),
),
if (publicKey.value != null) ...[
const Text(
'Confirm this is the public identity you want to use:',
),
const SizedBox(height: Grid.xs),
SelectableText(fullNpub(publicKey.value!)!),
const SizedBox(height: Grid.sm),
],
if (error.value != null)
Text(error.value!, style: TextStyle(color: context.colors.error)),
const SizedBox(height: Grid.xs),
FilledButton(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: const StadiumBorder(),
),
onPressed: busy.value ? null : submit,
child: Text(
busy.value
? 'Importing…'
: publicKey.value == null
? 'Preview identity'
: 'Import this identity',
),
),
],
children: children,
),
),
);

View File

@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
/// Shared setup-page typography, using the approved Inter treatment.
abstract final class OnboardingTypography {
static const title = TextStyle(
fontFamily: 'Inter',
fontSize: 30,
height: 36 / 30,
fontWeight: FontWeight.w400,
letterSpacing: -1,
);
static const subtitle = TextStyle(
fontFamily: 'Inter',
fontSize: 16,
height: 24 / 16,
fontWeight: FontWeight.w400,
letterSpacing: -0.25,
);
}

View File

@@ -19,3 +19,4 @@ export 'text_theme.dart' show bodyExtraSmallTextStyle;
export 'utility_surface_theme.dart';
export 'mobile_appearance.dart';
export 'chat_colors.dart';
export 'onboarding_typography.dart';

View File

@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme_extensions.dart';
/// Crisp mobile rendering of desktop BuzzLogoAnimation's v8 loading morph.
/// Uses its 880ms keyframes and easing, without the desktop noise filter.
class BeeLoadingMark extends HookWidget {
const BeeLoadingMark({super.key});
@override
Widget build(BuildContext context) {
final reduced = MediaQuery.disableAnimationsOf(context);
final controller = useAnimationController(
duration: const Duration(milliseconds: 880),
);
useEffect(() {
if (reduced) {
controller
..stop()
..value = 1;
} else {
controller.repeat(reverse: true);
}
return controller.stop;
}, [controller, reduced]);
return Semantics(
label: 'Loading',
liveRegion: true,
child: ExcludeSemantics(
child: RepaintBoundary(
child: CustomPaint(
size: const Size(80, 80 * 309 / 466),
painter: _BeeMorphPainter(controller, context.colors.onSurface),
),
),
),
);
}
}
class _BeeMorphPainter extends CustomPainter {
_BeeMorphPainter(this.animation, this.color) : super(repaint: animation);
final Animation<double> animation;
final Color color;
static const ease = Cubic(.16, 1, .3, 1);
double value(List<double> times, List<double> values) {
final t = animation.value;
for (var i = 1; i < times.length; i++) {
if (t <= times[i]) {
final progress = ((t - times[i - 1]) / (times[i] - times[i - 1])).clamp(
0.0,
1.0,
);
return values[i - 1] +
(values[i] - values[i - 1]) * ease.transform(progress);
}
}
return values.last;
}
@override
void paint(Canvas canvas, Size size) {
canvas.save();
canvas.scale(size.width / 466);
canvas.saveLayer(const Rect.fromLTWH(-20, -20, 506, 349), Paint());
final wingOpacity = value([0, .54, .55, 1], [0, 0, 1, 1]);
final wingPaint = Paint()
..color = color.withValues(alpha: color.a * wingOpacity);
for (final xs in const <List<double>>[
[233.0, 233, 76, 95, 91.7],
[233.0, 233, 390, 371, 374.3],
]) {
canvas.drawCircle(
Offset(value([0, .54, .74, .9, 1], xs), 154.5),
91.7,
wingPaint,
);
}
const bodyTimes = [0.0, .08, .16, .23, .35, .47, .6, 1.0];
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(
value(bodyTimes, [233.4, 200, 186, 186, 120, 132, 127, 128]),
value(bodyTimes, [154.5, 121, 108, 108, 42, -12, 3, 0]),
value(bodyTimes, [0, 66, 93, 93, 226, 204, 212, 210]),
value(bodyTimes, [0, 66, 93, 93, 226, 326, 304, 309]),
),
Radius.circular(value(bodyTimes, [0, 33, 46.5, 14, 40, 37, 33, 34])),
),
Paint()
..color = color.withValues(
alpha: color.a * value([0, .03, .08, 1], [0, 0, 1, 1]),
),
);
final eyePaint = Paint()
..blendMode = BlendMode.dstOut
..color = Colors.black.withValues(
alpha: value([0, .18, .2, 1], [0, 0, 1, 1]),
);
for (final xs in const <List<double>>[
[233.4, 233.4, 185, 196, 193.3, 193.3],
[233.4, 233.4, 283, 273, 276, 276],
]) {
canvas.drawOval(
Rect.fromCenter(
center: Offset(
value([0, .23, .45, .58, .68, 1], xs),
value(
[0, .23, .45, .58, .68, 1],
[154.5, 154.5, 76, 87, 84.4, 84.4],
),
),
width: 54,
height: 2 * value([0, .76, .84, .92, 1], [27, 27, 2, 27, 27]),
),
eyePaint,
);
}
for (final lower in [false, true]) {
final times = lower
? [0.0, .54, .72, .84, .94, 1.0]
: [0.0, .42, .6, .72, .82, 1.0];
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(
value(times, [
234.8,
234.8,
158,
169,
lower ? 166.9 : 166.3,
lower ? 166.9 : 166.3,
]),
lower ? 235.1 : 157.2,
value(times, [
0,
0,
153,
132,
lower ? 136.2 : 136.9,
lower ? 136.2 : 136.9,
]),
lower ? 37.6 : 38.3,
),
const Radius.circular(5),
),
Paint()
..blendMode = BlendMode.dstOut
..color = Colors.black.withValues(
alpha: value(lower ? [0, .54, .55, 1] : [0, .42, .43, 1], [
0,
0,
1,
1,
]),
),
);
}
canvas.restore();
canvas.restore();
}
@override
bool shouldRepaint(_BeeMorphPainter oldDelegate) =>
oldDelegate.color != color || oldDelegate.animation != animation;
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/cupertino.dart';
import '../theme/theme.dart';
/// Borderless community address field used by both landing and Add entry.
class IosCommunityLinkInput extends StatelessWidget {
const IosCommunityLinkInput({
super.key,
required this.controller,
this.focusNode,
this.autofocus = false,
this.enabled = true,
this.onChanged,
this.onSubmitted,
});
final TextEditingController controller;
final FocusNode? focusNode;
final bool autofocus;
final bool enabled;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
@override
Widget build(BuildContext context) => CupertinoTextField(
key: const Key('community-link-input'),
controller: controller,
focusNode: focusNode,
autofocus: autofocus,
enabled: enabled,
placeholder: 'Community link or address',
placeholderStyle: context.textTheme.titleLarge?.copyWith(
color: CupertinoColors.placeholderText.resolveFrom(context),
),
keyboardType: TextInputType.url,
textInputAction: TextInputAction.go,
autocorrect: false,
enableSuggestions: false,
padding: const EdgeInsets.symmetric(vertical: Grid.xs),
style: context.textTheme.titleLarge?.copyWith(
color: context.colors.onSurface,
),
cursorColor: context.colors.primary,
decoration: null,
onChanged: onChanged,
onSubmitted: onSubmitted,
);
}

View File

@@ -0,0 +1,80 @@
import 'dart:math' show max;
import 'package:flutter/material.dart';
import '../theme/theme.dart';
import 'bee_loading_mark.dart';
/// Centers the loading mark and heading together as one block.
class OnboardingLoadingPage extends StatelessWidget {
const OnboardingLoadingPage({super.key, required this.title, this.onCancel});
final String title;
final VoidCallback? onCancel;
@override
Widget build(BuildContext context) {
final theme = utilitySurfaceThemeData(Theme.of(context));
final safe = MediaQuery.paddingOf(context);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) onCancel?.call();
},
child: Theme(
data: theme,
child: Scaffold(
backgroundColor: theme.colorScheme.surface,
body: Stack(
children: [
Positioned.fill(
child: LayoutBuilder(
builder: (context, bounds) => SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: bounds.maxHeight),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: Grid.sm,
vertical: max(safe.top, safe.bottom) + Grid.sm,
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Center(child: BeeLoadingMark()),
const SizedBox(height: Grid.sm),
Semantics(
header: true,
namesRoute: true,
child: Text(
title,
textAlign: TextAlign.center,
style: OnboardingTypography.title.copyWith(
color: theme.colorScheme.onSurface,
),
),
),
],
),
),
),
),
),
),
if (onCancel != null)
Positioned(
left: Grid.sm,
right: Grid.sm,
bottom: safe.bottom + Grid.sm,
child: TextButton(
onPressed: onCancel,
child: const Text('Cancel'),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,214 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/semantics.dart';
import '../theme/theme.dart';
import 'ios_navigation_bar.dart';
/// Opaque onboarding chrome shared by account setup and identity import.
/// A scrollable minimum-height body keeps actions at the bottom without
/// clipping content when the keyboard or larger accessibility text is shown.
class OnboardingStepPage extends StatelessWidget {
const OnboardingStepPage({
super.key,
required this.title,
this.subtitle,
this.onConnectionOptions,
this.foregroundProgress = 0,
this.keyboardBottomSpacing = Grid.sm,
required this.onBack,
required this.content,
this.actions = const [],
});
final String title;
final String? subtitle;
final VoidCallback? onConnectionOptions;
final double foregroundProgress;
final double keyboardBottomSpacing;
final VoidCallback onBack;
final Widget content;
final List<Widget> actions;
@override
Widget build(BuildContext context) {
final theme = utilitySurfaceThemeData(Theme.of(context));
final ios = defaultTargetPlatform == TargetPlatform.iOS;
final buttonStyle = FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: const StadiumBorder(),
);
return Theme(
data: theme.copyWith(
filledButtonTheme: FilledButtonThemeData(style: buttonStyle),
outlinedButtonTheme: OutlinedButtonThemeData(style: buttonStyle),
textButtonTheme: TextButtonThemeData(style: buttonStyle),
),
child: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) onBack();
},
child: Scaffold(
backgroundColor: theme.colorScheme.surface,
body: SafeArea(
top: !ios,
child: ExcludeFocus(
excluding: foregroundProgress > 0,
child: ExcludeSemantics(
excluding: foregroundProgress > 0,
child: IgnorePointer(
ignoring: foregroundProgress > 0,
child: Opacity(
key: const Key('scanner-welcome-opacity'),
opacity: 1 - foregroundProgress,
child: Transform.scale(
key: const Key('scanner-welcome-scale'),
scale: MediaQuery.disableAnimationsOf(context)
? 1
: 1 - .04 * foregroundProgress,
child: ImageFiltered(
enabled:
!MediaQuery.disableAnimationsOf(context) &&
foregroundProgress > 0 &&
foregroundProgress < 1,
imageFilter: ImageFilter.blur(
sigmaX: 16 * foregroundProgress,
sigmaY: 16 * foregroundProgress,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (ios)
SizedBox(
height:
MediaQuery.paddingOf(context).top +
IosNavigationMetrics.of(
context,
).compactHeight,
child: IosNavigationBar(
title: '',
leading: IosNavigationAction(
label: 'Back',
symbol: 'chevron.backward',
onPressed: onBack,
),
),
)
else
Align(
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
),
child: IconButton(
tooltip: 'Back',
onPressed: onBack,
icon: const BackButtonIcon(),
),
),
),
Expanded(
child: CustomScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: EdgeInsets.fromLTRB(
Grid.sm,
Grid.sm,
Grid.sm,
MediaQuery.viewInsetsOf(
context,
).bottom >
0
? keyboardBottomSpacing
: Grid.sm,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
GestureDetector(
key: onConnectionOptions == null
? null
: const Key(
'developer-pairing-trigger',
),
excludeFromSemantics: true,
child: Semantics(
header: true,
namesRoute: true,
customSemanticsActions:
onConnectionOptions == null
? null
: {
const CustomSemanticsAction(
label:
'More connection options',
): onConnectionOptions!,
},
child: Text(
title,
style: OnboardingTypography
.title
.copyWith(
color: theme
.colorScheme
.onSurface,
),
),
),
),
if (subtitle != null) ...[
const SizedBox(height: Grid.xs),
Text(
subtitle!,
style: OnboardingTypography
.subtitle
.copyWith(
color: theme
.colorScheme
.onSurfaceVariant,
),
),
],
const SizedBox(height: Grid.xs),
content,
const Spacer(),
if (actions.isNotEmpty)
const SizedBox(height: Grid.sm),
for (
var i = 0;
i < actions.length;
i++
) ...[
if (i > 0)
const SizedBox(height: Grid.xxs),
actions[i],
],
],
),
),
),
],
),
),
],
),
),
),
),
),
),
),
),
),
),
);
}
}

View File

@@ -2721,7 +2721,7 @@ void main() {
expect(find.byType(FallbackPairingQrScanner), findsNothing);
expect(find.text('Scan a QR code'), findsOneWidget);
expect(find.text('Use pairing code'), findsNothing);
expect(find.byKey(const Key('developer-pairing-trigger')), findsOneWidget);
expect(find.text('Use a pairing code'), findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();

View File

@@ -0,0 +1,340 @@
import '../../helpers/two_finger_double_tap.dart';
import 'package:buzz/shared/hosted/builderlab_account.dart';
import 'package:buzz/shared/hosted/hosted_community_connection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/features/pairing/add_community_page.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:buzz/shared/community/community_grid_option.dart';
import 'dart:async';
import 'package:buzz/onboarding_app_entry.dart';
import 'package:buzz/hosted_app_gate.dart';
import 'package:buzz/shared/hosted/hosted_environment.dart';
import 'package:buzz/shared/hosted/hosted_flow.dart';
import 'package:buzz/shared/hosted/hosted_flow_provider.dart';
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
import 'package:buzz/shared/widgets/onboarding_step_page.dart';
import 'package:buzz/shared/widgets/bee_loading_mark.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/hosted_communities_fixture.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('Add uses landing input and leaves room above the keyboard', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(393, 852);
addTearDown(tester.view.reset);
for (final reduced in [false, true]) {
var pairingStarted = false;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(disableAnimations: reduced),
child: child!,
),
home: HookBuilder(
builder: (context) {
final entering = useState(false);
return AddCommunityPage(
enteringCommunityLink: entering.value,
onBack: () => entering.value = false,
onCommunityLink: () => entering.value = true,
onPairing: () => pairingStarted = true,
onScan: () {},
onOpenCommunity: (_) {},
onSubmitPairing: (_) {},
);
},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Use a pairing code'), findsNothing);
final firstTap = await tester.createGesture();
await firstTap.down(
const Offset(196, 400),
timeStamp: const Duration(milliseconds: 100),
);
await firstTap.up(timeStamp: const Duration(milliseconds: 120));
await tester.pump(const Duration(milliseconds: 50));
final secondTap = await tester.createGesture();
await secondTap.down(
const Offset(196, 400),
timeStamp: const Duration(milliseconds: 200),
);
await secondTap.up(timeStamp: const Duration(milliseconds: 220));
await tester.pumpAndSettle();
expect(find.text('Use a pairing code'), findsNothing);
await twoFingerDoubleTap(tester, const Offset(196, 400));
expect(find.text('Use a pairing code'), findsOneWidget);
await twoFingerDoubleTap(tester, const Offset(196, 400));
expect(find.text('Use a pairing code'), findsNothing);
await twoFingerDoubleTap(tester, const Offset(196, 400));
expect(pairingStarted, isFalse);
expect(find.text('Use a pairing code').hitTestable(), findsOneWidget);
expect(
tester.getTopLeft(find.text('Use a pairing code')).dy,
greaterThan(
tester.getBottomLeft(find.text('Enter a community link')).dy,
),
);
await tester.tap(find.text('Use a pairing code'));
expect(pairingStarted, isTrue);
final action = find.byKey(const Key('add-community-link-action'));
final button = tester.element(action);
await tester.tap(find.text('Enter a community link'));
await tester.pump();
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(tester.element(action), same(button));
final input = tester.widget<CupertinoTextField>(
find.byKey(const Key('community-link-input')),
);
expect(input.decoration, isNull);
expect(input.focusNode!.hasFocus, isTrue);
expect(852 - 300 - tester.getBottomLeft(action).dy, closeTo(12, 0.1));
await tester.enterText(
find.byKey(const Key('community-link-input')),
'example.com',
);
await tester.binding.handlePopRoute();
tester.view.viewInsets = const FakeViewPadding();
await tester.pumpAndSettle();
expect(find.text('Add a community'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
}
debugDefaultTargetPlatformOverride = null;
});
testWidgets('Home stays hidden until community choice and admission', (
tester,
) async {
addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue);
// Secure-storage mocks must remain in one widget-test zone.
for (final reduced in [false, true]) {
tester.platformDispatcher.accessibilityFeaturesTestValue =
FakeAccessibilityFeatures(disableAnimations: reduced);
FlutterSecureStorage.setMockInitialValues({});
PendingDeepLinkNotifier.debugUriStreamOverride =
const Stream<Uri>.empty();
final fixture = HostedCommunitiesFixture();
await fixture.initialize(widget: true);
fixture.rows = [fixture.row('one'), fixture.row('two')];
await tester.pumpWidget(
WidgetHelpers.testable(
disableAnimations: reduced,
overrides: [hostedFlowProvider.overrideWithValue(fixture.flow)],
child: const HostedAppGate(
authenticated: true,
child: Scaffold(body: Text('Home content')),
),
),
);
await tester.pumpAndSettle();
await fixture.flow.signInAccount();
await tester.pumpAndSettle();
expect(fixture.flow.stage, HostedFlowStage.chooseCommunities);
expect(fixture.admitted, isEmpty);
expect(find.text('Home content'), findsNothing);
expect(find.text('Select a community'), findsOneWidget);
expect(find.byType(BottomSheet), findsNothing);
expect(find.byTooltip('Back'), findsOneWidget);
// Cancelling restore must not open Add after the pending request returns.
final cancelledRestore = Completer<void>();
fixture.beforeAdmission = (_) => cancelledRestore.future;
await tester.ensureVisible(
find.byKey(const Key('community-switcher-add')),
);
await tester.tap(find.byKey(const Key('community-switcher-add')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byTooltip('Back'), findsNothing);
expect(find.byType(BeeLoadingMark), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pump();
expect(fixture.flow.stage, HostedFlowStage.chooseCommunities);
cancelledRestore.complete();
await tester.pumpAndSettle();
expect(find.byType(OnboardingAppEntry), findsNothing);
expect(find.text('Select a community'), findsOneWidget);
for (final conflict in [false, true]) {
fixture.beforeAdmission = (name) async {
if (name == 'two.communities.buzz.xyz') {
if (conflict) throw const SavedCommunityIdentityConflict();
throw AccountFailure.unavailable;
}
};
await tester.tap(find.byKey(const Key('community-switcher-add')));
await tester.pumpAndSettle();
expect(find.byType(OnboardingAppEntry), findsNothing);
await tester.tap(
find.text(
conflict ? 'Keep saved identity and skip' : 'Skip this community',
),
);
await tester.pumpAndSettle();
expect(find.byType(OnboardingAppEntry), findsOneWidget);
expect(fixture.openedCommunity, isNull);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Select a community'), findsOneWidget);
fixture.admitted.clear();
}
fixture.beforeAdmission = null;
// Add restores existing memberships, stays opaque, and returns to the
// picker on Back without the disposed Add route cancelling discovery.
await tester.ensureVisible(
find.byKey(const Key('community-switcher-add')),
);
await tester.tap(find.byKey(const Key('community-switcher-add')));
await tester.pumpAndSettle();
expect(fixture.admitted, hasLength(2));
expect(find.byType(OnboardingAppEntry), findsOneWidget);
expect(find.text('Home content').hitTestable(), findsNothing);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.byType(OnboardingAppEntry), findsNothing);
expect(find.text('Select a community'), findsOneWidget);
expect(fixture.flow.stage, HostedFlowStage.chooseCommunities);
fixture.admitted.clear();
final admission = Completer<void>();
fixture.beforeAdmission = (_) => admission.future;
await tester.tap(find.byType(CommunityGridOption).last);
await tester.pump();
// System Back cancels even while the outgoing picker is mounted.
await tester.binding.handlePopRoute();
await tester.pump();
expect(fixture.flow.stage, HostedFlowStage.chooseCommunities);
admission.complete();
await tester.pumpAndSettle();
expect(fixture.admitted, isEmpty);
final resumedAdmission = Completer<void>();
fixture.beforeAdmission = (_) => resumedAdmission.future;
await tester.tap(find.byType(CommunityGridOption).last);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Home content'), findsNothing);
expect(find.text('Joining your community'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.byTooltip('Back'), findsNothing);
expect(
tester.getBottomLeft(find.byType(BeeLoadingMark)).dy,
lessThan(tester.getTopLeft(find.text('Joining your community')).dy),
);
final bee = tester.widget<CustomPaint>(
find.descendant(
of: find.byType(BeeLoadingMark),
matching: find.byType(CustomPaint),
),
);
expect(bee.size.width, 80);
final mark = tester.getRect(find.byType(BeeLoadingMark));
expect(
mark.center.dx,
tester.view.physicalSize.width / tester.view.devicePixelRatio / 2,
);
expect(
(mark.top +
tester.getRect(find.text('Joining your community')).bottom) /
2,
tester.view.physicalSize.height / tester.view.devicePixelRatio / 2,
);
expect(
tester.getTopLeft(find.text('Joining your community')).dy - mark.bottom,
24,
);
expect(
tester.widget<Text>(find.text('Joining your community')).textAlign,
TextAlign.center,
);
expect(
MediaQuery.disableAnimationsOf(
tester.element(find.byType(BeeLoadingMark)),
),
reduced,
);
if (reduced) {
await tester.pumpAndSettle(
const Duration(milliseconds: 100),
EnginePhase.sendSemanticsUpdate,
const Duration(seconds: 1),
);
}
expect(fixture.admitted, isEmpty);
final opening = Completer<void>();
fixture.beforeOpen = () => opening.future;
resumedAdmission.complete();
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Home content'), findsNothing);
opening.complete();
await tester.pumpAndSettle();
expect(fixture.admitted, [
'two.communities.buzz.xyz',
'one.communities.buzz.xyz',
]);
expect(fixture.openedCommunity, 'wss://two.communities.buzz.xyz');
expect(find.text('Home content').hitTestable(), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
await fixture.dispose();
}
PendingDeepLinkNotifier.debugUriStreamOverride = null;
}, skip: !HostedEnvironment.enabled);
testWidgets('step actions remain reachable with large text and keyboard', (
tester,
) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(320, 640);
addTearDown(tester.view.reset);
var continued = false;
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(
size: Size(320, 640),
textScaler: TextScaler.linear(2),
viewInsets: EdgeInsets.only(bottom: 260),
),
child: OnboardingStepPage(
title: 'Bring your identity with you',
subtitle: 'Use the identity from your other device to continue.',
onBack: () {},
content: const Text('Your saved identity stays on your device.'),
actions: [
FilledButton(
onPressed: () => continued = true,
child: const Text('Continue'),
),
],
),
),
),
);
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Continue'));
await tester.pumpAndSettle();
await tester.tap(find.text('Continue'));
expect(continued, isTrue);
expect(tester.takeException(), isNull);
});
}

View File

@@ -1,3 +1,4 @@
import 'package:buzz/shared/community/community_grid_option.dart';
import 'dart:ui' as ui;
import 'package:buzz/shared/hosted/hosted_community_creation.dart';
@@ -67,7 +68,8 @@ void main() {
.hitTestable();
expect(
tester.getTopLeft(scanCard).dy - tester.getBottomLeft(subtitle).dy,
closeTo(24, 1),
// The transitioning heading reserves the taller of both text blocks.
greaterThanOrEqualTo(24),
);
tester.view.physicalSize = const Size(320, 640);
await tester.pumpAndSettle();
@@ -267,7 +269,11 @@ void main() {
expect(find.byType(HostedConnectPage), findsNothing);
await tester.tap(find.text('Continue with Builderlab'));
await tester.pumpAndSettle();
expect(find.byType(CheckboxListTile), findsNothing);
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(find.byType(CommunityGridOption), findsWidgets);
await tester.tap(find.byType(CommunityGridOption).first);
await tester.pumpAndSettle();
expect(find.byType(CommunityGridOption), findsNothing);
expect(f.admitted, [
'one.communities.buzz.xyz',
'two.communities.buzz.xyz',
@@ -281,6 +287,10 @@ void main() {
};
await tester.tap(find.text('Continue with Builderlab'));
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(find.byType(CommunityGridOption), findsWidgets);
await tester.tap(find.byType(CommunityGridOption).first);
await tester.pumpAndSettle();
expect(find.text('Keep saved identity and skip'), findsOneWidget);
expect(find.text('1 community added and kept.'), findsOneWidget);
await tester.tap(find.text('Keep saved identity and skip'));
@@ -293,18 +303,30 @@ void main() {
};
await tester.tap(find.text('Continue with Builderlab'));
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(find.byType(CommunityGridOption), findsWidgets);
await tester.tap(find.byType(CommunityGridOption).first);
await tester.pumpAndSettle();
expect(find.text('1 community added and kept.'), findsOneWidget);
expect(find.text('Try again').hitTestable(), findsNothing);
expect(find.text('Skip this community').hitTestable(), findsOneWidget);
await tester.tap(find.text('Back to choices'));
await tester.pumpAndSettle();
expect(find.byType(CheckboxListTile), findsOneWidget);
expect(find.byType(CommunityGridOption), findsNWidgets(2));
expect(
find.descendant(
of: find.byType(CheckboxListTile),
of: find.byType(CommunityGridOption),
matching: find.text('two.communities.buzz.xyz'),
),
findsWidgets,
);
await tester.tap(find.byType(CommunityGridOption).first);
await tester.pumpAndSettle();
await tester.tap(find.text('Skip this community'));
await tester.pumpAndSettle();
expect(f.admitted, ['one.communities.buzz.xyz']);
expect(f.openedCommunity, 'wss://one.communities.buzz.xyz');
expect(find.byType(HostedConnectPage), findsNothing);
}
},
skip: HostedEnvironment.authDomains.isEmpty,

View File

@@ -1,4 +1,7 @@
import 'package:buzz/shared/hosted/hosted_community_creation.dart';
import 'package:buzz/shared/community/community_grid_option.dart';
import 'package:buzz/shared/hosted/account_identity_store.dart';
import 'package:buzz/shared/hosted/hosted_flow.dart';
import 'package:buzz/shared/hosted/hosted_environment.dart';
import 'package:buzz/features/pairing/pairing_provider.dart';
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
@@ -65,7 +68,11 @@ void main() {
f.bound = null;
f.offerProfile = false;
await f.mount(tester);
await f.tap(tester, 'Continue with Builderlab');
// Account restoration may resume independently of the Add choices.
await f.flow.signInAccount();
await f.settle(tester);
expect(f.flow.stage, HostedFlowStage.setupIdentity);
expect(f.probes, isEmpty);
expect(find.text('Create my Buzz identity'), findsOneWidget);
expect(find.text('Import existing identity'), findsOneWidget);
expect(await f.store.read(), isNull);
@@ -114,7 +121,11 @@ void main() {
await f.initialize(localIdentity: false);
f.offerProfile = false;
await f.mount(tester);
await f.tap(tester, 'Continue with Builderlab');
// Account restoration may resume independently of the Add choices.
await f.flow.signInAccount();
await f.settle(tester);
expect(f.flow.stage, HostedFlowStage.setupIdentity);
expect(f.probes, isEmpty);
expect(find.text('Create my Buzz identity'), findsNothing);
expect(find.text('Import existing identity'), findsOneWidget);
await f.tap(tester, 'Import existing identity');
@@ -140,21 +151,25 @@ void main() {
expect(f.requests.where((r) => r.url.path.endsWith('/recovery')), isEmpty);
});
journey('system Back leaves community creation', (tester, f) async {
journey('system Back leaves community link entry', (tester, f) async {
await f.initialize();
await f.mount(tester);
await f.tap(tester, 'Create a community');
await f.tap(tester, 'Enter a community link');
expect(
tester.widget<PairingPage>(find.byType(PairingPage)).creatingCommunity,
tester
.widget<PairingPage>(find.byType(PairingPage))
.enteringCommunityLink,
isTrue,
);
await tester.binding.handlePopRoute();
await f.settle(tester);
expect(
tester.widget<PairingPage>(find.byType(PairingPage)).creatingCommunity,
tester
.widget<PairingPage>(find.byType(PairingPage))
.enteringCommunityLink,
isFalse,
);
expect(find.text('Create a community').hitTestable(), findsOneWidget);
expect(find.text('Enter a community link').hitTestable(), findsOneWidget);
});
journey('retiring profile setup removes both profile and avatar routes', (
@@ -164,7 +179,13 @@ void main() {
await f.initialize();
f.rows.add(f.row('one'));
await f.mount(tester);
await f.tap(tester, 'Continue with Builderlab');
// Account restoration may resume independently of the Add choices.
await f.flow.signInAccount();
await f.settle(tester);
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(f.probes, isEmpty);
await tester.tap(find.byType(CommunityGridOption).first);
await f.settle(tester);
await tester.tap(find.byTooltip('Add profile photo'));
await f.settle(tester);
expect(find.byType(ProfileSetupAvatarEditor), findsOneWidget);
@@ -184,7 +205,13 @@ void main() {
await f.initialize();
f.rows.add(f.row('one'));
await f.mount(tester);
await f.tap(tester, 'Continue with Builderlab');
// Account restoration may resume independently of the Add choices.
await f.flow.signInAccount();
await f.settle(tester);
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(f.probes, isEmpty);
await tester.tap(find.byType(CommunityGridOption).first);
await f.settle(tester);
await tester.tap(find.byTooltip('Add profile photo'));
await f.settle(tester);
final url = emojiAvatarDataUrl('🐝', 0xFFFFE75C);
@@ -233,7 +260,8 @@ void main() {
findsOneWidget,
);
expect(f.activeFlow, isNull);
await f.tap(tester, 'Back');
await tester.tap(find.byTooltip('Back').hitTestable());
await f.settle(tester);
expect(f.requests, isEmpty);
}
final notifier = f.container.read(pairingProvider.notifier);
@@ -495,7 +523,9 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
try {
await tester.tap(find.text('Leave setup').hitTestable());
expect(find.byTooltip('Back'), findsNothing);
// External teardown still fences pending enrollment.
await f.flow.leave();
await tester.pump();
} finally {
enrollmentReturn.complete();
@@ -590,7 +620,14 @@ void main() {
f.subject = 'new-owner';
f.bound = boundAccount ? f.secondKeys.public : null;
if (boundAccount) f.cloudKeys.add(f.secondKeys);
await f.tap(tester, 'Continue with Builderlab');
// Account restoration may resume independently of the Add choices.
await f.flow.signInAccount();
await f.settle(tester);
expect(
f.flow.stage,
boundAccount ? HostedFlowStage.idle : HostedFlowStage.chooseIdentity,
);
expect(f.probes, isEmpty);
if (!boundAccount) {
expect(find.text('Saved identity'), findsOneWidget);
expect(f.sent('/verify'), isEmpty);
@@ -626,7 +663,11 @@ void main() {
await f.storage.save(legacy);
await f.storage.saveActiveId(legacy.id);
await f.mount(tester);
await f.tap(tester, 'Continue with Builderlab');
// Account restoration may resume independently of the Add choices.
await f.flow.signInAccount();
await f.settle(tester);
expect(f.flow.stage, HostedFlowStage.chooseIdentity);
expect(f.probes, isEmpty);
expect(find.text('Use Builderlab identity'), findsNWidgets(2));
await f.tap(tester, fullNpub(f.secondKeys.public)!);
expect((await f.store.read())!.pubkey, f.secondKeys.public);
@@ -641,7 +682,9 @@ void main() {
findsNothing,
);
f.rejectList = false;
await f.tap(tester, 'Continue with Builderlab');
await f.tap(tester, 'Try again');
expect(f.flow.stage, HostedFlowStage.idle);
expect(f.probes, isEmpty);
expect(
find.text(
'No communities available to add. Scan a QR code or use an invite link.',
@@ -651,8 +694,14 @@ void main() {
expect(find.text('Scan a QR code'), findsOneWidget);
expect(find.text('Use pairing code'), findsNothing);
f.rows.addAll([f.row('one'), f.row('two')]);
await f.tap(tester, 'Continue with Builderlab');
// Joined communities are added automatically after choosing the identity.
// Account restoration may resume independently of the Add choices.
await f.flow.signInAccount();
await f.settle(tester);
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(f.probes, isEmpty);
await tester.tap(find.byType(CommunityGridOption).first);
await f.settle(tester);
// Continue explicitly admits the selected communities.
expect(find.byType(CheckboxListTile).hitTestable(), findsNothing);
expect(
find.byType(ProfileSetupPage, skipOffstage: false),
@@ -705,18 +754,32 @@ void main() {
(tester, f) async {
await f.initialize(localIdentity: false);
f.cloudKeys.addAll([f.secondKeys, f.keys]);
f.rows.add(f.row('one'));
f.rows.addAll([f.row('one'), f.row('two')]);
f.offerProfile = false;
f.profileUnavailable = unreadableProfile;
await f.mount(tester);
await f.tap(tester, 'Continue with Builderlab');
// One discovered community is admitted automatically.
// Account restoration may resume independently of the Add choices.
await f.flow.signInAccount();
await f.settle(tester);
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(f.probes, isEmpty);
await tester.tap(find.byType(CommunityGridOption).last);
await f.settle(tester);
// All memberships are restored; the second is the chosen destination.
expect(f.profileWrites, isEmpty);
expect(
find.byType(ProfileSetupPage, skipOffstage: false),
findsNothing,
);
final saved = (await f.storage.loadAll()).single;
final communities = await f.storage.loadAll();
expect(communities, hasLength(2));
expect(
communities.map((c) => c.name),
containsAll(['one.communities.buzz.xyz', 'two.communities.buzz.xyz']),
);
final saved = communities.singleWhere(
(c) => c.name == 'two.communities.buzz.xyz',
);
expect(saved.nsec, f.keys.nsec);
expect(saved.pubkey, f.keys.public);
expect((await f.store.read())!.pubkey, f.keys.public);
@@ -907,9 +970,9 @@ void main() {
await f.initialize();
f.loseCreateResponse = true;
await f.mount(tester);
await f.tap(tester, 'Create a community');
await tester.enterText(find.byType(TextField).hitTestable(), 'new-home');
await f.tap(tester, 'Continue');
// Creation is entered elsewhere; retain its restart/persistence journey.
await f.flow.start(HostedCreateCommunity('new-home'));
await f.settle(tester);
expect(find.text('Try again').hitTestable(), findsOneWidget);
expect(f.owned, hasLength(1));
expect(await f.storage.loadAll(), isEmpty);
@@ -980,7 +1043,8 @@ void main() {
// Owner mismatch requires the correct identity, not an automatic
// retry. Explicitly abandon setup, then reopen the same link after
// the server-side owner conflict has been corrected.
await f.tap(tester, 'Leave setup');
await tester.tap(find.byTooltip('Back').hitTestable());
await f.settle(tester);
expect((await f.store.read())!.pubkey, f.keys.public);
await f.link(tester, 'one');
}

View File

@@ -1,3 +1,5 @@
import '../../helpers/two_finger_double_tap.dart';
import 'package:buzz/shared/community/community_grid_option.dart';
import 'package:buzz/shared/hosted/hosted_environment.dart';
import 'dart:async';
import 'dart:convert';
@@ -19,6 +21,7 @@ import 'package:buzz/shared/hosted/self_hosted_entry.dart';
import 'package:buzz/shared/hosted/account_identity_store.dart';
import 'package:buzz/shared/widgets/buzz_sheet_header.dart';
import 'package:flutter/material.dart';
import 'package:buzz/shared/widgets/bee_loading_mark.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -95,12 +98,9 @@ void main() {
}
Future<void> pair() async {
final trigger = find
.byKey(const Key('developer-pairing-trigger'))
.hitTestable();
await tester.tap(trigger);
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(trigger);
final trigger = find.byKey(const Key('developer-pairing-trigger'));
await twoFingerDoubleTap(tester, tester.getCenter(trigger));
await tester.tap(find.text('Use a pairing code').hitTestable());
await tester.pumpAndSettle();
await tester.enterText(
find.byType(TextField).hitTestable(),
@@ -128,7 +128,15 @@ void main() {
}
};
await openEntry();
await tester.tap(find.text('Continue with Builderlab').hitTestable());
expect(find.text('Create a community'), findsNothing);
expect(find.text('Continue with Builderlab'), findsNothing);
expect(find.text('Use a pairing code'), findsNothing);
// An independently resumed account restore still owns a root modal
// above Add; Add itself no longer offers account sign-in or creation.
await f.flow.signInAccount();
await tester.pumpAndSettle();
expect(f.admitted, isEmpty);
await tester.tap(find.byType(CommunityGridOption).first);
await tester.pumpAndSettle();
expect(f.admitted, ['one.communities.buzz.xyz']);
expect(f.flow.stage, HostedFlowStage.communityConflict);
@@ -139,19 +147,17 @@ void main() {
expect(find.text('Back to choices').hitTestable(), findsOneWidget);
await tester.tap(find.text('Back to choices').hitTestable());
await tester.pumpAndSettle();
expect(find.byType(CheckboxListTile).hitTestable(), findsOneWidget);
expect(find.byType(CommunityGridOption).hitTestable(), findsNWidgets(2));
expect(
(tester
.widget<CheckboxListTile>(
find.byType(CheckboxListTile).hitTestable(),
)
.title
as Text)
.data,
tester
.widget<CommunityGridOption>(
find.byType(CommunityGridOption).hitTestable().last,
)
.name,
'two.communities.buzz.xyz',
);
f.beforeAdmission = null;
await tester.tap(find.text('Continue').hitTestable());
await tester.tap(find.byType(CommunityGridOption).first);
await tester.pumpAndSettle();
expect(f.admitted, [
'one.communities.buzz.xyz',
@@ -170,13 +176,13 @@ void main() {
await pair();
expect(f.flow.accountOnly, isFalse);
expect(f.flow.stage, HostedFlowStage.working);
expect(find.text('Leave setup').hitTestable(), findsOneWidget);
expect(find.byTooltip('Back').hitTestable(), findsNothing);
expect(find.byType(BeeLoadingMark).hitTestable(), findsOneWidget);
expect(find.byType(BuzzSheetHeader), findsNothing);
expect(
find.byType(CircularProgressIndicator).hitTestable(),
find.byType(HostedConnectPage, skipOffstage: false),
findsOneWidget,
);
expect(find.byType(BuzzSheetHeader), findsNothing);
expect(find.byType(HostedConnectPage), findsNothing);
admission.completeError(AccountFailure.unavailable);
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.blocked);
@@ -187,7 +193,7 @@ void main() {
findsOneWidget,
);
expect(find.text('Try again').hitTestable(), findsOneWidget);
expect(find.text('Leave setup').hitTestable(), findsOneWidget);
expect(find.byTooltip('Back').hitTestable(), findsOneWidget);
expect(find.byType(BuzzSheetHeader), findsNothing);
f.beforeAdmission = null;
await tester.tap(find.text('Try again').hitTestable());
@@ -209,7 +215,7 @@ void main() {
await f.flow.leave();
await tester.pumpAndSettle();
expect(f.flow.stage, HostedFlowStage.idle);
await tester.tap(find.text('Back').hitTestable());
await tester.tap(find.byTooltip('Back').hitTestable());
await tester.pumpAndSettle();
expect(find.text('Enter a community link').hitTestable(), findsOneWidget);
expect(container.read(hostedInlineEntryProvider), isTrue);
@@ -218,9 +224,28 @@ void main() {
await openEntry();
}
// Drive the real deep-link dispatcher, sheet, service and auth persistence.
// The selected button becomes Continue without replacing its element.
final linkAction = find.byKey(const Key('add-community-link-action'));
final originalAction = tester.element(linkAction);
await tester.tap(find.text('Enter a community link').hitTestable());
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(tester.element(linkAction), same(originalAction));
await tester.pumpAndSettle();
expect(
tester
.widget<Transform>(find.byKey(const Key('add-community-link-rise')))
.transform
.storage[13],
0,
);
await tester.tap(find.text('Continue').hitTestable());
await tester.pumpAndSettle();
expect(
find.text('Enter a valid community address or invite link.'),
findsOneWidget,
);
// Drive the real deep-link dispatcher, sheet, service and auth persistence.
await tester.enterText(
find.byKey(const Key('community-link-input')).hitTestable(),
'new-community.example',
@@ -254,17 +279,14 @@ void main() {
final joinHeight = tester.getSize(find.byType(SelfHostedEntrySheet)).height;
await tester.tap(find.text('Import a Nostr key').hitTestable());
await tester.pumpAndSettle();
expect(find.byType(BuzzSheetHeader), findsOneWidget);
expect(find.byType(BuzzSheetHeader), findsNothing);
expect(find.text('Import identity'), findsOneWidget);
expect(
ModalRoute.of(tester.element(find.byType(NsecImportSheet))),
same(joinRoute),
isNot(same(joinRoute)),
);
expect(
tester.getSize(find.byType(SelfHostedEntrySheet)).height,
greaterThan(joinHeight),
);
await tester.tap(find.byTooltip('Close sheet'));
expect(find.byType(SelfHostedEntrySheet), findsNothing);
await tester.tap(find.byTooltip('Back'));
await tester.pumpAndSettle();
expect(find.byType(SelfHostedEntrySheet), findsOneWidget);
expect(find.byType(NsecImportSheet), findsNothing);
@@ -289,8 +311,8 @@ void main() {
await tester.tap(find.text('Import this identity'));
await tester.pumpAndSettle();
expect(probes, 1);
await tester.tap(find.byTooltip('Close sheet'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Back'));
// Completion races the outgoing route animation.
pendingProbe.complete();
await tester.pumpAndSettle();
expect(find.text('Join community'), findsOneWidget);
@@ -343,7 +365,7 @@ void main() {
await openEntry();
await tester.tap(find.text('Enter a community link').hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.text('Back').hitTestable());
await tester.tap(find.byTooltip('Back').hitTestable());
await tester.pumpAndSettle();
expect(container.read(hostedInlineEntryProvider), isTrue);
await tester.pageBack();

View File

@@ -293,14 +293,19 @@ void main() {
expect(find.text('Does your desktop app show this code?'), findsNothing);
});
testWidgets('uses Cancel as the only visible SAS exit', (tester) async {
testWidgets('fullscreen pairing supports Cancel and system Back', (
tester,
) async {
final notifier = _ConfirmingSasPairingNotifier();
await tester.pumpWidget(
ProviderScope(
overrides: [pairingProvider.overrideWith(() => notifier)],
child: MaterialApp(
theme: AppTheme.dark(),
home: const PairingPage(addingCommunity: true),
home: const PairingPage(
addingCommunity: true,
connectionLanding: true,
),
),
),
);
@@ -312,6 +317,9 @@ void main() {
await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
expect(notifier.denied, isTrue);
notifier.denied = false;
await tester.binding.handlePopRoute();
expect(notifier.denied, isTrue);
});
testWidgets('keeps only Back on the clear add-community header', (

View File

@@ -286,89 +286,105 @@ void main() {
},
);
testWidgets(
'welcome content follows the scanner spring and returns on close',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async {
return call.method == 'usesDynamicIslandQrScannerPortal'
? true
: null;
});
await tester.binding.setSurfaceSize(const Size(393, 852));
addTearDown(() => tester.binding.setSurfaceSize(null));
for (final reduced in [false, true]) {
await tester.pumpWidget(
WidgetHelpers.testable(
disableAnimations: reduced,
child: const PairingPage(
connectionLanding: true,
showConnectionOptions: false,
),
testWidgets('welcome and Add follow the scanner spring and return on close', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async {
return call.method == 'usesDynamicIslandQrScannerPortal'
? true
: null;
});
await tester.binding.setSurfaceSize(const Size(393, 852));
addTearDown(() => tester.binding.setSurfaceSize(null));
for (final (reduced, adding) in [
(false, false),
(true, false),
(false, true),
(true, true),
]) {
await tester.pumpWidget(
WidgetHelpers.testable(
disableAnimations: reduced,
child: PairingPage(
addingCommunity: adding,
connectionLanding: true,
showConnectionOptions: false,
),
);
await tester.pumpAndSettle();
final opacity = find.byKey(const Key('scanner-welcome-opacity'));
final scale = find.byKey(const Key('scanner-welcome-scale'));
expect(tester.widget<Opacity>(opacity).opacity, 1);
await tester.tap(find.text('Scan a QR code').hitTestable());
await tester.pump();
// Opening must begin without waiting for the welcome double-tap timeout.
),
);
await tester.pumpAndSettle();
final opacity = find.byKey(const Key('scanner-welcome-opacity'));
final scale = find.byKey(const Key('scanner-welcome-scale'));
expect(tester.widget<Opacity>(opacity).opacity, 1);
if (adding) {
// Only foreground controls transition; the opaque page stays fixed.
expect(
find.byKey(const ValueKey('dynamic-island-qr-scanner-portal')),
findsOneWidget,
);
await tester.pump(const Duration(milliseconds: 100));
if (!reduced) {
final portal = find.byKey(
const ValueKey('dynamic-island-qr-scanner-portal'),
);
final progress = (tester.getSize(portal).height - 36) / (363 - 36);
expect(
tester.widget<Opacity>(opacity).opacity,
closeTo(1 - progress, 0.001),
);
expect(
tester.widget<Transform>(scale).transform.storage[0],
closeTo(1 - 0.04 * progress, 0.001),
);
}
await tester.pumpAndSettle();
expect(tester.widget<Opacity>(opacity).opacity, 0);
expect(
tester.widget<Transform>(scale).transform.storage[0],
closeTo(reduced ? 1 : 0.96, 0.001),
);
expect(
find
.ancestor(
of: find.text('Scan a QR code'),
matching: find.byType(FilledButton),
)
.hitTestable(),
find.descendant(of: opacity, matching: find.byType(Scaffold)),
findsNothing,
);
await tester.tapAt(const Offset(196.5, 700));
await tester.pumpAndSettle();
expect(tester.widget<Opacity>(opacity).opacity, 1);
expect(tester.widget<Transform>(scale).transform.storage[0], 1);
expect(
find
.ancestor(
of: find.text('Scan a QR code'),
matching: find.byType(FilledButton),
)
.hitTestable(),
findsOneWidget,
find.ancestor(of: opacity, matching: find.byType(Scaffold)),
findsWidgets,
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
}
debugDefaultTargetPlatformOverride = null;
},
);
await tester.tap(find.text('Scan a QR code').hitTestable());
await tester.pump();
// Opening must begin without waiting for the welcome double-tap timeout.
expect(
find.byKey(const ValueKey('dynamic-island-qr-scanner-portal')),
findsOneWidget,
);
await tester.pump(const Duration(milliseconds: 100));
if (!reduced) {
final portal = find.byKey(
const ValueKey('dynamic-island-qr-scanner-portal'),
);
final progress = (tester.getSize(portal).height - 36) / (363 - 36);
expect(
tester.widget<Opacity>(opacity).opacity,
closeTo(1 - progress, 0.001),
);
expect(
tester.widget<Transform>(scale).transform.storage[0],
closeTo(1 - 0.04 * progress, 0.001),
);
}
await tester.pumpAndSettle();
expect(tester.widget<Opacity>(opacity).opacity, 0);
expect(
tester.widget<Transform>(scale).transform.storage[0],
closeTo(reduced ? 1 : 0.96, 0.001),
);
expect(
find
.ancestor(
of: find.text('Scan a QR code'),
matching: find.byType(FilledButton),
)
.hitTestable(),
findsNothing,
);
await tester.tapAt(const Offset(196.5, 700));
await tester.pumpAndSettle();
expect(tester.widget<Opacity>(opacity).opacity, 1);
expect(tester.widget<Transform>(scale).transform.storage[0], 1);
expect(
find
.ancestor(
of: find.text('Scan a QR code'),
matching: find.byType(FilledButton),
)
.hitTestable(),
findsOneWidget,
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
}
debugDefaultTargetPlatformOverride = null;
});
testWidgets(
'an early dismissal keeps its frame and settles back into the island',

View File

@@ -81,6 +81,8 @@ class HostedCommunitiesFixture {
late BuilderlabAccountClient account;
late final HostedFlow flow;
final admitted = <String>[];
String? openedCommunity;
Future<void> Function()? beforeOpen;
final remembered = <String>[];
final requests = <String>[];
List<Map<String, dynamic>> rows = [];
@@ -171,6 +173,10 @@ class HostedCommunitiesFixture {
opened = uri;
return true;
},
openLandingCommunity: (link, subject, pubkey, current) async {
await beforeOpen?.call();
if (current()) openedCommunity = link.relayUrl;
},
enterCommunity: (link, subject, pubkey, current) async {
final target = link as AddCommunityDeepLink;
await beforeAdmission?.call(target.name);

View File

@@ -0,0 +1,16 @@
import 'package:flutter_test/flutter_test.dart';
Future<void> twoFingerDoubleTap(WidgetTester tester, Offset position) async {
for (final time in [100, 200]) {
final first = await tester.createGesture();
final second = await tester.createGesture();
await first.down(position, timeStamp: Duration(milliseconds: time));
await second.down(
position + const Offset(40, 0),
timeStamp: Duration(milliseconds: time + 5),
);
await first.up(timeStamp: Duration(milliseconds: time + 20));
await second.up(timeStamp: Duration(milliseconds: time + 25));
}
await tester.pumpAndSettle();
}

View File

@@ -42,6 +42,33 @@ void main() {
);
}
test(
'selecting a skipped relay retries it as the landing community',
() async {
final f = await fixture();
f.rows = [f.row('one'), f.row('two'), f.row('three')];
f.beforeAdmission = (name) async {
if (name != 'one.communities.buzz.xyz') {
throw AccountFailure.unavailable;
}
};
await f.flow.signInAccount();
await f.flow.addSelectedCommunities();
await f.flow.skipFailedCommunity();
expect(f.flow.stage, HostedFlowStage.blocked);
f.flow.backToCommunityChoices();
f.flow.chooseLandingCommunity('wss://two.communities.buzz.xyz');
f.beforeAdmission = null;
await f.flow.addSelectedCommunities();
expect(f.admitted, [
'one.communities.buzz.xyz',
'two.communities.buzz.xyz',
'three.communities.buzz.xyz',
]);
expect(f.openedCommunity, 'wss://two.communities.buzz.xyz');
},
);
test('malformed list targets cannot authorize admission', () async {
final f = await fixture();
f.rows = [
@@ -61,6 +88,9 @@ void main() {
}
};
await f.flow.signInAccount();
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
expect(f.admitted, isEmpty);
await f.flow.addSelectedCommunities();
expect(f.admitted, ['a-one.communities.buzz.xyz']);
f.expired = true;
await f.flow.retry();
@@ -80,7 +110,8 @@ void main() {
);
final completed = Completer<void>();
f.flow.addListener(() {
if (f.flow.stage == HostedFlowStage.complete && !completed.isCompleted) {
if (f.flow.stage == HostedFlowStage.chooseCommunities &&
!completed.isCompleted) {
completed.complete();
}
});
@@ -88,6 +119,7 @@ void main() {
BrowserAuthCode('other-code', 'a' * 43, {'type': 'account'}),
);
await completed.future.timeout(const Duration(seconds: 2));
await f.flow.addSelectedCommunities();
expect(f.admitted, [
'a-one.communities.buzz.xyz',
'b-one.communities.buzz.xyz',