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 |
| --- | --- |
| ![Previous create
screen](https://github.com/user-attachments/assets/353b3b41-57b4-4a3a-9c21-10ea1c0becf2)
| ![Create using the join
template](https://github.com/user-attachments/assets/5a0d9d8d-cace-44b1-a3c8-bbe65f04a2e0)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-11-04 20:32:55 +00:00
committed by GitHub
parent 90de03aa63
commit ce94656b0d
5 changed files with 215 additions and 32 deletions

View File

@@ -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,

View File

@@ -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'),

View File

@@ -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,

View File

@@ -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),
),

View File

@@ -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 {