mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
106
mobile/lib/features/pairing/add_community_page.dart
Normal file
106
mobile/lib/features/pairing/add_community_page.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
59
mobile/lib/features/pairing/community_link_focus.dart
Normal file
59
mobile/lib/features/pairing/community_link_focus.dart
Normal 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;
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
35
mobile/lib/shared/community/community_grid.dart
Normal file
35
mobile/lib/shared/community/community_grid.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
51
mobile/lib/shared/community/community_grid_add.dart
Normal file
51
mobile/lib/shared/community/community_grid_add.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
92
mobile/lib/shared/community/community_grid_option.dart
Normal file
92
mobile/lib/shared/community/community_grid_option.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
19
mobile/lib/shared/theme/onboarding_typography.dart
Normal file
19
mobile/lib/shared/theme/onboarding_typography.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
168
mobile/lib/shared/widgets/bee_loading_mark.dart
Normal file
168
mobile/lib/shared/widgets/bee_loading_mark.dart
Normal 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;
|
||||
}
|
||||
48
mobile/lib/shared/widgets/ios_community_link_input.dart
Normal file
48
mobile/lib/shared/widgets/ios_community_link_input.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
80
mobile/lib/shared/widgets/onboarding_loading_page.dart
Normal file
80
mobile/lib/shared/widgets/onboarding_loading_page.dart
Normal 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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
214
mobile/lib/shared/widgets/onboarding_step_page.dart
Normal file
214
mobile/lib/shared/widgets/onboarding_step_page.dart
Normal 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],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
340
mobile/test/features/onboarding/fullscreen_onboarding_test.dart
Normal file
340
mobile/test/features/onboarding/fullscreen_onboarding_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
16
mobile/test/helpers/two_finger_double_tap.dart
Normal file
16
mobile/test/helpers/two_finger_double_tap.dart
Normal 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();
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user