diff --git a/apipod/apipod_server/lib/src/endpoints/nip05/nip05_endpoint.dart b/apipod/apipod_server/lib/src/endpoints/nip05/nip05_endpoint.dart index 0b7b855f..6e3b1b82 100644 --- a/apipod/apipod_server/lib/src/endpoints/nip05/nip05_endpoint.dart +++ b/apipod/apipod_server/lib/src/endpoints/nip05/nip05_endpoint.dart @@ -45,7 +45,7 @@ class Nip05Endpoint extends Endpoint { Future checkName( Session session, - String name, + String nameUser, String domain, ) async { final result = NameCheckResult( @@ -53,18 +53,20 @@ class Nip05Endpoint extends Endpoint { suggestions: [], ); + final cleanedName = nameUser.replaceAll(" ", ""); + // Check if name is in disallowed list - if (disallowedWords.contains(name.toLowerCase())) { + if (disallowedWords.contains(cleanedName.toLowerCase())) { result.reason = 'This name is not allowed'; // Generate suggestions - result.suggestions = generateSuggestions(name); + result.suggestions = generateSuggestions(cleanedName); return result; } // Check if name exists in database - var existingName = await Nip05Data.db.findFirstRow( + final existingName = await Nip05Data.db.findFirstRow( session, - where: (t) => t.name.equals(name) & t.domain.equals(domain), + where: (t) => t.name.equals(cleanedName) & t.domain.equals(domain), ); if (existingName == null) { @@ -73,7 +75,7 @@ class Nip05Endpoint extends Endpoint { return result; } else { result.reason = 'This name is already taken'; - result.suggestions = generateSuggestions(name); + result.suggestions = generateSuggestions(cleanedName); return result; } } diff --git a/lib/presentation_layer/atoms/username_input.dart b/lib/presentation_layer/atoms/username_input.dart new file mode 100644 index 00000000..b9da54b3 --- /dev/null +++ b/lib/presentation_layer/atoms/username_input.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +import '../../config/palette.dart'; + +class UsernameInputField extends StatefulWidget { + final String username; + final String domain; + final Widget? trailing; + + final Function(String) onChange; + + const UsernameInputField({ + super.key, + required this.username, + required this.domain, + required this.onChange, + this.trailing, + }); + + @override + State createState() => _UsernameInputFieldState(); +} + +class _UsernameInputFieldState extends State { + final FocusNode _nameFocusNode = FocusNode(); + final TextEditingController _nameController = TextEditingController(); + + @override + void initState() { + super.initState(); + _nameController.text = widget.username; + } + + @override + void dispose() { + _nameFocusNode.dispose(); + _nameController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(UsernameInputField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.username != oldWidget.username) { + _nameController.text = widget.username; + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + width: MediaQuery.of(context).size.width * 0.95, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: IntrinsicWidth( + child: TextField( + textAlign: TextAlign.left, + cursorRadius: const Radius.circular(50), + maxLines: 1, + textAlignVertical: TextAlignVertical.center, + autofocus: true, + focusNode: _nameFocusNode, + controller: _nameController, + autofillHints: const [AutofillHints.username], + decoration: const InputDecoration( + hintText: '_', + contentPadding: EdgeInsets.all(0), + hintStyle: TextStyle( + color: Palette.white, + letterSpacing: 1.1, + ), + alignLabelWithHint: true, + border: InputBorder.none, + isDense: true, + ), + onChanged: (value) { + widget.onChange(value); + }, + style: const TextStyle( + color: Palette.lightGray, + letterSpacing: 1.1, + fontSize: 28, + ), + ), + ), + ), + Text( + widget.domain, + style: const TextStyle( + color: Palette.gray, + letterSpacing: 1.1, + fontSize: 28, + ), + ), + if (widget.trailing != null) widget.trailing! + ], + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding.dart index f6981760..7f61aa7f 100644 --- a/lib/presentation_layer/routes/nostr/onboarding/onboarding.dart +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding.dart @@ -12,6 +12,7 @@ import 'onboarding_page01.dart'; import 'onboarding_picture.dart'; import 'onboarding_profile.dart'; import 'onboarding_starter_pack.dart'; +import 'onboarding_username.dart'; class NostrOnboarding extends ConsumerStatefulWidget { const NostrOnboarding({ @@ -86,7 +87,7 @@ class _NostrOnboardingState extends ConsumerState void initState() { super.initState(); _tabController = TabController( - length: 6, + length: 7, initialIndex: 0, vsync: this, ); @@ -176,6 +177,13 @@ class _NostrOnboardingState extends ConsumerState _nextTab(); }, ), + OnboardingUsername( + userInfo: signUpInfo, + submitCallback: (username) { + signUpInfo.nip05 = username; + _nextTab(); + }, + ), OnboardingPicture( pictureCallback: () { _nextTab(); diff --git a/lib/presentation_layer/routes/nostr/onboarding/onboarding_username.dart b/lib/presentation_layer/routes/nostr/onboarding/onboarding_username.dart new file mode 100644 index 00000000..d53b165e --- /dev/null +++ b/lib/presentation_layer/routes/nostr/onboarding/onboarding_username.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; + +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; + +import '../../../../config/palette.dart'; +import '../../../../domain_layer/entities/onboarding_user_info.dart'; +import '../../../atoms/long_button.dart'; +import '../../../atoms/username_input.dart'; +import '../../../providers/serverpod_provider.dart'; + +const mydomain = 'camelus.app'; + +class OnboardingUsername extends ConsumerStatefulWidget { + final Function submitCallback; + + final OnboardingUserInfo userInfo; + + const OnboardingUsername({ + super.key, + required this.submitCallback, + required this.userInfo, + }); + @override + ConsumerState createState() => _OnboardingUsernameState(); +} + +class _OnboardingUsernameState extends ConsumerState { + final FocusNode _nameFocusNode = FocusNode(); + + bool _isChecking = false; + bool _isAvailable = false; + bool _show = false; + List _suggestions = []; + Timer? _debounce; + + String username = ""; + + @override + void initState() { + super.initState(); + + setState(() { + username = widget.userInfo.nip05?.split('@')[0] ?? ''; + }); + + if (username.isNotEmpty) { + _onUsernameChange(username); + } + } + + _onUsernameChange(String username) { + setState(() { + _isChecking = true; + }); + + if (username.isNotEmpty) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 500), () { + _checkUsernameAvailability(username); + }); + } else { + _debounce?.cancel(); + setState(() { + _isAvailable = false; + + _show = false; + _suggestions = []; + }); + } + } + + Future _checkUsernameAvailability(String username) async { + if (username.isEmpty) return; + + setState(() { + _isChecking = true; + _show = true; + }); + + try { + final serverpodProv = ref.read(serverpodProvider); + + final checkRes = + await serverpodProv.client.nip05.checkName(username, mydomain); + + if (checkRes.isAvailable) { + widget.userInfo.nip05 = '$username@$mydomain'; + } else { + widget.userInfo.nip05 = ''; + } + + setState(() { + _isAvailable = checkRes.isAvailable; + _suggestions = checkRes.suggestions; + _isChecking = false; + _show = true; + }); + } catch (e) { + setState(() { + _isChecking = false; + _isAvailable = false; + _suggestions = []; + _show = true; + }); + } + } + + void _selectSuggestion(String suggestion) { + _onUsernameChange(suggestion); + setState(() { + username = suggestion; + _suggestions = []; + }); + } + + @override + void dispose() { + _debounce?.cancel(); + + _nameFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Palette.background, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Spacer(flex: 20), + // Suggestions list + if (_suggestions.isNotEmpty) + Container( + height: 120, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Suggestions:", + style: TextStyle( + color: Palette.lightGray, + fontSize: 16, + ), + ), + const SizedBox(height: 5), + Expanded( + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _suggestions.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: GestureDetector( + onTap: () => + _selectSuggestion(_suggestions[index]), + child: Chip( + backgroundColor: Palette.darkGray, + label: Text( + _suggestions[index], + style: const TextStyle( + color: Palette.lightGray, + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + + // Username status indicator + + Row( + children: [ + const SizedBox(height: 10), + UsernameInputField( + domain: "@camelus.app", + onChange: (name) { + _onUsernameChange(name); + }, + username: username, + trailing: Row( + children: [ + const SizedBox(width: 5), + if (_show && _isAvailable && !_isChecking) + Icon( + PhosphorIcons.sealCheck(), + size: 29, + ), + if (_show && !_isAvailable && !_isChecking) + Icon( + PhosphorIcons.sealWarning(), + size: 29, + ), + if (_show && _isChecking) + Icon( + PhosphorIcons.sealQuestion(), + size: 29, + ), + // Icon( + // PhosphorIcons.seal(), + // size: 29, + // ), + ], + ), + ), + ], + ), + + const Spacer( + flex: 1, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + width: 400, + height: 40, + child: longButton( + name: _isAvailable ? "next" : "skip", + onPressed: (() { + _nameFocusNode.unfocus(); + if (_isAvailable) { + widget.submitCallback(username); + } else { + widget.submitCallback(""); + } + }), + inverted: _isAvailable, + ), + ), + const SizedBox( + height: 15, + ), + ], + ), + ), + ); + } +}