mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
fix(mobile): use join template for community creation (#9072)
Community creation now uses the same mobile template, borderless iOS input, and keyboard-coordinated transition as joining, replacing the old welcome screen and boxed field. Name validation, separate form drafts, reduced motion, and accessible error announcements are preserved. Validation: analysis and 4,407 Flutter tests pass (45 skipped, four workers); independent review passed. Kenny approved the simulator preview. Agent verification of the full native transition remains pending; the after snapshot below is a Flutter widget render. | Before | After | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
|
||||
import 'community_link_focus.dart';
|
||||
import '../../shared/widgets/ios_community_link_input.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../shared/widgets/onboarding_route.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
import '../../shared/deeplink/community_link_input.dart';
|
||||
import '../../shared/hosted/hosted_community_creation.dart';
|
||||
import '../../shared/hosted/hosted_environment.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/onboarding_step_page.dart';
|
||||
|
||||
@@ -28,6 +34,8 @@ class AddCommunityPage extends StatelessWidget {
|
||||
this.onFindCommunities,
|
||||
this.onCreateCommunity,
|
||||
this.enteringCommunityLink = false,
|
||||
this.creatingCommunity = false,
|
||||
this.onSubmitCommunity,
|
||||
this.existingUser = false,
|
||||
this.scannerProgress = 0,
|
||||
this.busy = false,
|
||||
@@ -47,6 +55,8 @@ class AddCommunityPage extends StatelessWidget {
|
||||
final VoidCallback? onFindCommunities;
|
||||
final VoidCallback? onCreateCommunity;
|
||||
final bool enteringCommunityLink;
|
||||
final bool creatingCommunity;
|
||||
final ValueChanged<String>? onSubmitCommunity;
|
||||
final bool existingUser;
|
||||
final double scannerProgress;
|
||||
final bool busy;
|
||||
@@ -57,7 +67,7 @@ class AddCommunityPage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final step = enteringCommunityLink
|
||||
final step = enteringCommunityLink || creatingCommunity
|
||||
? 'link'
|
||||
: existingUser
|
||||
? 'pair'
|
||||
@@ -66,6 +76,8 @@ class AddCommunityPage extends StatelessWidget {
|
||||
? _AddCommunityConnection(
|
||||
key: const ValueKey('add-community-connection'),
|
||||
enteringLink: enteringCommunityLink,
|
||||
creatingCommunity: creatingCommunity,
|
||||
onSubmitCommunity: onSubmitCommunity,
|
||||
onboarding: onboarding,
|
||||
onFindCommunities: onFindCommunities,
|
||||
onCreateCommunity: onCreateCommunity,
|
||||
|
||||
@@ -5,6 +5,8 @@ class _AddCommunityConnection extends HookWidget {
|
||||
const _AddCommunityConnection({
|
||||
super.key,
|
||||
required this.enteringLink,
|
||||
required this.creatingCommunity,
|
||||
this.onSubmitCommunity,
|
||||
required this.onboarding,
|
||||
this.onFindCommunities,
|
||||
this.onCreateCommunity,
|
||||
@@ -22,6 +24,8 @@ class _AddCommunityConnection extends HookWidget {
|
||||
});
|
||||
|
||||
final bool enteringLink;
|
||||
final bool creatingCommunity;
|
||||
final ValueChanged<String>? onSubmitCommunity;
|
||||
final bool onboarding;
|
||||
final VoidCallback? onFindCommunities;
|
||||
final VoidCallback? onCreateCommunity;
|
||||
@@ -39,21 +43,40 @@ class _AddCommunityConnection extends HookWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = useTextEditingController();
|
||||
final linkController = useTextEditingController();
|
||||
final createController = useTextEditingController();
|
||||
final enteringForm = enteringLink || creatingCommunity;
|
||||
final controller = creatingCommunity ? createController : linkController;
|
||||
final focus = useFocusNode();
|
||||
final error = useState<String?>(null);
|
||||
final pairingVisible = useState(false);
|
||||
useEffect(() {
|
||||
error.value = null;
|
||||
return null;
|
||||
}, [creatingCommunity, enteringLink]);
|
||||
void revealPairing() => pairingVisible.value = !pairingVisible.value;
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final ios = defaultTargetPlatform == TargetPlatform.iOS;
|
||||
final keyboardReady = useCommunityLinkFocus(
|
||||
context,
|
||||
enteringLink: enteringLink,
|
||||
enteringLink: enteringForm,
|
||||
focus: focus,
|
||||
);
|
||||
|
||||
void submit() {
|
||||
if (busy || !canSubmit) return;
|
||||
if (creatingCommunity) {
|
||||
final name = controller.text.trim().toLowerCase();
|
||||
if (!HostedCreateCommunity.validName(name)) {
|
||||
error.value =
|
||||
'Use letters, numbers and single hyphens (up to 63 characters).';
|
||||
return;
|
||||
}
|
||||
error.value = null;
|
||||
focus.unfocus();
|
||||
onSubmitCommunity?.call(name);
|
||||
return;
|
||||
}
|
||||
final link = parseCommunityLinkInput(controller.text.trim());
|
||||
if (link == null) {
|
||||
error.value = 'Enter a valid community address or invite link.';
|
||||
@@ -64,18 +87,24 @@ class _AddCommunityConnection extends HookWidget {
|
||||
}
|
||||
|
||||
return _PairingRevealSurface(
|
||||
onReveal: enteringLink || busy || scannerProgress > 0
|
||||
onReveal: enteringForm || busy || scannerProgress > 0
|
||||
? null
|
||||
: revealPairing,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: enteringLink && (!ios || keyboardReady) ? 1 : 0),
|
||||
tween: Tween(end: enteringForm && (!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
|
||||
title: creatingCommunity
|
||||
? 'Create a community'
|
||||
: enteringLink
|
||||
? 'Enter a community link'
|
||||
: 'Add a community',
|
||||
subtitle: creatingCommunity
|
||||
? 'Choose an address for your community.'
|
||||
: enteringForm
|
||||
? 'Paste an invite link or community address.'
|
||||
: onboarding
|
||||
? accountStatus ??
|
||||
@@ -84,13 +113,13 @@ class _AddCommunityConnection extends HookWidget {
|
||||
onBack: onBack,
|
||||
foregroundProgress: scannerProgress,
|
||||
keyboardBottomSpacing: Grid.twelve,
|
||||
onConnectionOptions: enteringLink || busy ? null : revealPairing,
|
||||
onConnectionOptions: enteringForm || busy ? null : revealPairing,
|
||||
content: ExcludeSemantics(
|
||||
excluding: !enteringLink,
|
||||
excluding: !enteringForm,
|
||||
child: ExcludeFocus(
|
||||
excluding: !enteringLink,
|
||||
excluding: !enteringForm,
|
||||
child: IgnorePointer(
|
||||
ignoring: !enteringLink,
|
||||
ignoring: !enteringForm,
|
||||
child: Opacity(
|
||||
opacity: progress,
|
||||
child: Transform.translate(
|
||||
@@ -105,6 +134,14 @@ class _AddCommunityConnection extends HookWidget {
|
||||
children: [
|
||||
IosCommunityLinkInput(
|
||||
controller: controller,
|
||||
inputKey: Key(
|
||||
creatingCommunity
|
||||
? 'community-create-input'
|
||||
: 'community-link-input',
|
||||
),
|
||||
placeholder: creatingCommunity
|
||||
? 'Community address'
|
||||
: 'Community link or address',
|
||||
focusNode: focus,
|
||||
enabled: !busy,
|
||||
onChanged: (_) {
|
||||
@@ -113,21 +150,35 @@ class _AddCommunityConnection extends HookWidget {
|
||||
},
|
||||
onSubmitted: (_) => submit(),
|
||||
),
|
||||
if (creatingCommunity)
|
||||
Text(
|
||||
'.${HostedEnvironment.communitySuffix}',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (error.value ?? errorMessage
|
||||
case final problem?)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: Grid.xs),
|
||||
child: Text(
|
||||
problem,
|
||||
style: TextStyle(
|
||||
color: context.colors.error,
|
||||
child: Semantics(
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
problem,
|
||||
style: TextStyle(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: TextField(
|
||||
key: const Key('community-link-input'),
|
||||
key: Key(
|
||||
creatingCommunity
|
||||
? 'community-create-input'
|
||||
: 'community-link-input',
|
||||
),
|
||||
controller: controller,
|
||||
focusNode: focus,
|
||||
enabled: !busy,
|
||||
@@ -136,8 +187,15 @@ class _AddCommunityConnection extends HookWidget {
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Community link or address',
|
||||
hintText: 'community.example.com',
|
||||
labelText: creatingCommunity
|
||||
? 'Community address'
|
||||
: 'Community link or address',
|
||||
hintText: creatingCommunity
|
||||
? 'my-community'
|
||||
: 'community.example.com',
|
||||
helperText: creatingCommunity
|
||||
? '.${HostedEnvironment.communitySuffix}'
|
||||
: null,
|
||||
errorText: error.value ?? errorMessage,
|
||||
),
|
||||
onChanged: (_) {
|
||||
@@ -152,7 +210,7 @@ class _AddCommunityConnection extends HookWidget {
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (!enteringLink && errorMessage != null)
|
||||
if (!enteringForm && errorMessage != null)
|
||||
Semantics(
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
@@ -161,11 +219,11 @@ class _AddCommunityConnection extends HookWidget {
|
||||
),
|
||||
),
|
||||
ExcludeSemantics(
|
||||
excluding: enteringLink,
|
||||
excluding: enteringForm,
|
||||
child: ExcludeFocus(
|
||||
excluding: enteringLink,
|
||||
excluding: enteringForm,
|
||||
child: IgnorePointer(
|
||||
ignoring: enteringLink,
|
||||
ignoring: enteringForm,
|
||||
child: Opacity(
|
||||
opacity: 1 - progress,
|
||||
child: FilledButton(
|
||||
@@ -194,9 +252,9 @@ class _AddCommunityConnection extends HookWidget {
|
||||
),
|
||||
side: BorderSide.none,
|
||||
),
|
||||
onPressed: busy || (enteringLink && !canSubmit)
|
||||
onPressed: busy || (enteringForm && !canSubmit)
|
||||
? null
|
||||
: enteringLink
|
||||
: enteringForm
|
||||
? submit
|
||||
: onCommunityLink,
|
||||
child: AnimatedSwitcher(
|
||||
@@ -204,18 +262,18 @@ class _AddCommunityConnection extends HookWidget {
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 150),
|
||||
child: Text(
|
||||
enteringLink
|
||||
enteringForm
|
||||
? busy
|
||||
? 'Connecting…'
|
||||
: 'Continue'
|
||||
: onboarding
|
||||
? 'Enter community link'
|
||||
: 'Enter a community link',
|
||||
key: ValueKey(enteringLink),
|
||||
key: ValueKey(enteringForm),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onboarding && !enteringLink) ...[
|
||||
if (onboarding && !enteringForm) ...[
|
||||
if (onFindCommunities != null)
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -235,7 +293,7 @@ class _AddCommunityConnection extends HookWidget {
|
||||
child: const Text('Create a community'),
|
||||
),
|
||||
],
|
||||
if (pairingVisible.value && !enteringLink)
|
||||
if (pairingVisible.value && !enteringForm)
|
||||
TextButton(
|
||||
onPressed: busy ? null : onPairing,
|
||||
child: const Text('Use a pairing code'),
|
||||
|
||||
@@ -300,8 +300,7 @@ class PairingPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
)
|
||||
: (addingCommunity || (signedInOnboarding && !creatingCommunity)) &&
|
||||
connectionLanding
|
||||
: (addingCommunity || signedInOnboarding) && connectionLanding
|
||||
? ValueListenableBuilder<double>(
|
||||
valueListenable: islandScannerProgress,
|
||||
builder: (context, progress, _) => AddCommunityPage(
|
||||
@@ -312,6 +311,8 @@ class PairingPage extends HookConsumerWidget {
|
||||
: null,
|
||||
onCreateCommunity: signedInOnboarding ? onCreateCommunity : null,
|
||||
enteringCommunityLink: enteringCommunityLink,
|
||||
creatingCommunity: creatingCommunity,
|
||||
onSubmitCommunity: onSubmitCommunity,
|
||||
existingUser: existingUser,
|
||||
onBack: onWelcomeBack ?? () => Navigator.of(context).pop(),
|
||||
onCommunityLink: onCommunityLink,
|
||||
|
||||
@@ -8,6 +8,8 @@ class IosCommunityLinkInput extends StatelessWidget {
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.inputKey = const Key('community-link-input'),
|
||||
this.placeholder = 'Community link or address',
|
||||
this.autofocus = false,
|
||||
this.enabled = true,
|
||||
this.onChanged,
|
||||
@@ -16,6 +18,8 @@ class IosCommunityLinkInput extends StatelessWidget {
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode? focusNode;
|
||||
final Key inputKey;
|
||||
final String placeholder;
|
||||
final bool autofocus;
|
||||
final bool enabled;
|
||||
final ValueChanged<String>? onChanged;
|
||||
@@ -23,12 +27,12 @@ class IosCommunityLinkInput extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => CupertinoTextField(
|
||||
key: const Key('community-link-input'),
|
||||
key: inputKey,
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
autofocus: autofocus,
|
||||
enabled: enabled,
|
||||
placeholder: 'Community link or address',
|
||||
placeholder: placeholder,
|
||||
placeholderStyle: context.textTheme.titleLarge?.copyWith(
|
||||
color: CupertinoColors.placeholderText.resolveFrom(context),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -21,6 +23,112 @@ import '../../helpers/widget_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('PairingPage', () {
|
||||
testWidgets('create reuses join form, motion, validation and Back', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
await tester.binding.setSurfaceSize(const Size(393, 852));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
for (final reduced in [false, true]) {
|
||||
String? submitted;
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
disableAnimations: reduced,
|
||||
child: HookBuilder(
|
||||
builder: (context) {
|
||||
final creating = useState(false);
|
||||
final joining = useState(false);
|
||||
return PairingPage(
|
||||
connectionLanding: true,
|
||||
signedInOnboarding: true,
|
||||
creatingCommunity: creating.value,
|
||||
enteringCommunityLink: joining.value,
|
||||
onCreateCommunity: () => creating.value = true,
|
||||
onCommunityLink: () => joining.value = true,
|
||||
onSubmitCommunity: (name) => submitted = name,
|
||||
onWelcomeBack: () {
|
||||
creating.value = false;
|
||||
joining.value = false;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final action = find.byKey(const Key('add-community-link-action'));
|
||||
final originalAction = tester.element(action);
|
||||
await tester.tap(find.text('Create a community'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.element(action), same(originalAction));
|
||||
expect(find.bySemanticsLabel('Buzz'), findsNothing);
|
||||
expect(
|
||||
find.text('Choose an address for your community.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
final input = find.byKey(const Key('community-create-input'));
|
||||
final field = tester.widget<CupertinoTextField>(input);
|
||||
expect(field.decoration, isNull);
|
||||
expect(field.focusNode!.hasFocus, isTrue);
|
||||
expect(
|
||||
tester
|
||||
.widget<Transform>(
|
||||
find.byKey(const Key('add-community-link-rise')),
|
||||
)
|
||||
.transform
|
||||
.getTranslation()
|
||||
.y,
|
||||
0,
|
||||
);
|
||||
await tester.enterText(input, 'not a name');
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNull);
|
||||
expect(
|
||||
find.text(
|
||||
'Use letters, numbers and single hyphens (up to 63 characters).',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.text(
|
||||
'Use letters, numbers and single hyphens (up to 63 characters).',
|
||||
),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Semantics && widget.properties.liveRegion == true,
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.enterText(input, ' My-Community ');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.go);
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, 'my-community');
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Add a community'), findsOneWidget);
|
||||
await tester.tap(find.text('Enter community link'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
final joinInput = tester.widget<CupertinoTextField>(
|
||||
find.byKey(const Key('community-link-input')),
|
||||
);
|
||||
expect(joinInput.controller!.text, isEmpty);
|
||||
expect(joinInput.decoration, isNull);
|
||||
expect(joinInput.focusNode!.hasFocus, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'signed-in onboarding has ordered actions and switcher Add stays separate',
|
||||
(tester) async {
|
||||
|
||||
Reference in New Issue
Block a user