diff --git a/lib/controllers/edit_profile_controller.dart b/lib/controllers/edit_profile_controller.dart index 1769ac1b..37c8e652 100644 --- a/lib/controllers/edit_profile_controller.dart +++ b/lib/controllers/edit_profile_controller.dart @@ -27,7 +27,7 @@ class EditProfileController extends GetxController { RxBool isLoading = false.obs; Rx usernameAvailable = false.obs; - + Rx usernameChecking = false.obs; bool removeImage = false; bool showSuccessSnackbar = false; @@ -259,49 +259,30 @@ class EditProfileController extends GetxController { // Update USERNAME if (isUsernameChanged()) { - var usernameAvail = await isUsernameAvailable( - usernameController.text.trim(), - ); - if (!usernameAvail) { - usernameAvailable.value = false; - customSnackbar( - AppLocalizations.of(Get.context!)!.usernameUnavailable, - AppLocalizations.of(Get.context!)!.usernameInvalidOrTaken, - LogType.error, - ); - - SemanticsService.announce( - AppLocalizations.of(Get.context!)!.usernameInvalidOrTaken, - TextDirection.ltr, - ); - return; - } - // Create new doc of New Username - await tables.createRow( - databaseId: userDatabaseID, - tableId: usernameTableID, - rowId: usernameController.text.trim(), - data: {'email': authStateController.email}, - ); - try { - // Delete Old Username doc, so Username can be re-usable + await tables.createRow( + databaseId: userDatabaseID, + tableId: usernameTableID, + rowId: usernameController.text.trim(), + data: {'email': authStateController.email}, + ); + await tables.deleteRow( databaseId: userDatabaseID, tableId: usernameTableID, rowId: oldUsername, ); + + await tables.updateRow( + databaseId: userDatabaseID, + tableId: usersTableID, + rowId: authStateController.uid!, + data: {"username": usernameController.text.trim()}, + ); } catch (e) { log(e.toString()); } - - await tables.updateRow( - databaseId: userDatabaseID, - tableId: usersTableID, - rowId: authStateController.uid!, - data: {"username": usernameController.text.trim()}, - ); } //Update user DISPLAY-NAME diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 595e44de..cfb563f5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -928,7 +928,7 @@ "@stable": { "description": "A label indicating a stable, non-beta version of the app." }, - "usernameCharacterLimit": "Username should contain more than 5 characters.", + "usernameCharacterLimit": "Username should contain more than 7 characters.", "@usernameCharacterLimit": { "description": "Error message when a chosen username is too short." }, @@ -1767,7 +1767,24 @@ "description": "Confirmation text asking the user if they want to delete a message." }, "thisMessageWasDeleted": "This message was deleted", -"failedToDeleteMessage": "Failed to delete message" +"@thisMessageWasDeleted": { + "description": "Status text shown when a previously sent message has been deleted." +}, + +"failedToDeleteMessage": "Failed to delete message", +"@failedToDeleteMessage": { + "description": "Error message shown when the system is unable to delete a message." +}, + +"usernameInvalidFormat": "Please enter a valid username. Only letters, numbers, dots, underscores, and hyphens are allowed.", +"@usernameInvalidFormat": { + "description": "Validation error displayed when the user enters a username with unsupported characters." +}, + +"usernameAlreadyTaken": "This username is already taken. Try a different one.", +"@usernameAlreadyTaken": { + "description": "Error shown when the chosen username is unavailable because another user has already registered it." +} } \ No newline at end of file diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a6d4d56b..9dac2895 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -721,7 +721,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get usernameCharacterLimit => - 'Username should contain more than 5 characters.'; + 'Username should contain more than 7 characters.'; @override String get submit => 'Submit'; @@ -1355,4 +1355,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get failedToDeleteMessage => 'Failed to delete message'; + + @override + String get usernameInvalidFormat => + 'Please enter a valid username. Only letters, numbers, dots, underscores, and hyphens are allowed.'; + + @override + String get usernameAlreadyTaken => + 'This username is already taken. Try a different one.'; } diff --git a/lib/views/screens/edit_profile_screen.dart b/lib/views/screens/edit_profile_screen.dart index 7adbc6cb..013d5965 100644 --- a/lib/views/screens/edit_profile_screen.dart +++ b/lib/views/screens/edit_profile_screen.dart @@ -1,11 +1,13 @@ import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:loading_animation_widget/loading_animation_widget.dart'; import 'package:resonate/themes/theme_controller.dart'; +import 'package:resonate/utils/debouncer.dart'; +import 'package:resonate/utils/enums/log_type.dart'; import 'package:resonate/utils/ui_sizes.dart'; import 'package:resonate/views/widgets/loading_dialog.dart'; +import 'package:resonate/views/widgets/snackbar.dart'; import 'package:resonate/l10n/app_localizations.dart'; import '../../controllers/auth_state_controller.dart'; @@ -22,6 +24,7 @@ class EditProfileScreen extends StatelessWidget { final AuthStateController authStateController = Get.put( AuthStateController(), ); + final debouncer = Debouncer(milliseconds: 800); @override Widget build(BuildContext context) { @@ -30,9 +33,7 @@ class EditProfileScreen extends StatelessWidget { !(editProfileController.isLoading.value || editProfileController.isThereUnsavedChanges()), onPopInvokedWithResult: (didPop, result) async { - if (didPop) { - return; - } + if (didPop) return; if (!editProfileController.isLoading.value && editProfileController.isThereUnsavedChanges()) { @@ -112,7 +113,6 @@ class EditProfileScreen extends StatelessWidget { keyboardType: TextInputType.text, autocorrect: false, decoration: InputDecoration( - // hintText: "Name", labelText: AppLocalizations.of(context)!.name, prefixIcon: Icon(Icons.abc_rounded), ), @@ -120,8 +120,18 @@ class EditProfileScreen extends StatelessWidget { SizedBox(height: UiSizes.height_20), Obx( () => TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + maxLength: 36, validator: (value) { - if (value!.length > 5) { + if (value!.length >= 7) { + final validUsername = RegExp( + r'^[a-zA-Z0-9._-]+$', + ).hasMatch(value.trim()); + if (!validUsername) { + return AppLocalizations.of( + context, + )!.usernameInvalidFormat; + } return null; } else { return AppLocalizations.of( @@ -131,11 +141,53 @@ class EditProfileScreen extends StatelessWidget { }, controller: controller.usernameController, onChanged: (value) async { - if (value.length > 5) { - controller.usernameAvailable.value = - await controller.isUsernameAvailable( - value.trim(), + Get.closeCurrentSnackbar(); + + if (value.length >= 7) { + final validUsername = RegExp( + r'^[a-zA-Z0-9._-]+$', + ).hasMatch(value.trim()); + + if (!validUsername || value.trim().length > 36) { + controller.usernameAvailable.value = false; + controller.usernameChecking.value = false; + + customSnackbar( + AppLocalizations.of( + context, + )!.usernameUnavailable, + AppLocalizations.of( + context, + )!.usernameInvalidFormat, + LogType.error, + snackbarDuration: 1, + ); + return; + } + + controller.usernameChecking.value = true; + controller.usernameAvailable.value = false; + + debouncer.run(() async { + final available = await controller + .isUsernameAvailable(value.trim()); + + controller.usernameChecking.value = false; + controller.usernameAvailable.value = available; + + if (!available) { + customSnackbar( + AppLocalizations.of( + context, + )!.usernameUnavailable, + AppLocalizations.of( + context, + )!.usernameAlreadyTaken, + LogType.error, + snackbarDuration: 1, ); + } + }); } else { controller.usernameAvailable.value = false; } @@ -143,10 +195,11 @@ class EditProfileScreen extends StatelessWidget { keyboardType: TextInputType.text, autocorrect: false, decoration: InputDecoration( - // hintText: "Username", labelText: AppLocalizations.of(context)!.username, prefixIcon: const Icon(Icons.person), - suffixIcon: controller.usernameAvailable.value + suffixIcon: + !controller.usernameChecking.value && + controller.usernameAvailable.value ? const Icon( Icons.verified_outlined, color: Colors.green, @@ -182,11 +235,13 @@ class EditProfileScreen extends StatelessWidget { () => SizedBox( width: double.maxFinite, child: ElevatedButton( - onPressed: () async { - if (!controller.isLoading.value) { - await controller.saveProfile(); - } - }, + onPressed: + (!controller.isLoading.value && + controller.usernameAvailable.value) + ? () async { + await controller.saveProfile(); + } + : null, child: controller.isLoading.value ? Center( child: @@ -298,12 +353,9 @@ class EditProfileScreen extends StatelessWidget { Column( children: [ IconButton( - tooltip: AppLocalizations.of( - context, - )!.clickPictureCamera, + tooltip: AppLocalizations.of(context)!.clickPictureCamera, onPressed: () { Navigator.pop(context); - // Display Loading Dialog loadingDialog(context); editProfileController.pickImageFromCamera(); }, @@ -322,8 +374,6 @@ class EditProfileScreen extends StatelessWidget { tooltip: AppLocalizations.of(context)!.pickImageGallery, onPressed: () { Navigator.pop(context); - - // Display Loading Dialog loadingDialog(context); editProfileController.pickImageFromGallery(); },