diff --git a/mobile/lib/features/pairing/add_community_page.dart b/mobile/lib/features/pairing/add_community_page.dart index 98f0f62779..8627552e91 100644 --- a/mobile/lib/features/pairing/add_community_page.dart +++ b/mobile/lib/features/pairing/add_community_page.dart @@ -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? 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, diff --git a/mobile/lib/features/pairing/add_community_page/add_community_connection.dart b/mobile/lib/features/pairing/add_community_page/add_community_connection.dart index fd94852a9e..12d92e8329 100644 --- a/mobile/lib/features/pairing/add_community_page/add_community_connection.dart +++ b/mobile/lib/features/pairing/add_community_page/add_community_connection.dart @@ -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? 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(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( - 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'), diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index bf2251e92a..3449a88a20 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -300,8 +300,7 @@ class PairingPage extends HookConsumerWidget { ), ), ) - : (addingCommunity || (signedInOnboarding && !creatingCommunity)) && - connectionLanding + : (addingCommunity || signedInOnboarding) && connectionLanding ? ValueListenableBuilder( 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, diff --git a/mobile/lib/shared/widgets/ios_community_link_input.dart b/mobile/lib/shared/widgets/ios_community_link_input.dart index 59fd7aa2a8..87f2ecff2a 100644 --- a/mobile/lib/shared/widgets/ios_community_link_input.dart +++ b/mobile/lib/shared/widgets/ios_community_link_input.dart @@ -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? 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), ), diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index dbe3499c28..aae574de21 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -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(input); + expect(field.decoration, isNull); + expect(field.focusNode!.hasFocus, isTrue); + expect( + tester + .widget( + 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( + 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 {