diff --git a/.gitignore b/.gitignore index c9583676..0cdd1ba4 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ migrate_working_dir/ .pub/ /build/ +node_modules +android/app/google-services.json + # Symbolication related app.*.symbols @@ -61,10 +64,15 @@ android/app/src/main/res/values-night/styles.xml android/app/src/profile/AndroidManifest.xml android/gradle/wrapper/gradle-wrapper.properties ios/.gitignore +ios/Flutter/flutter_export_environment.sh +ios/Flutter/Generated.xcconfig +ios/Runner/GeneratedPluginRegistrant.h +ios/Runner/GeneratedPluginRegistrant.m ios/Flutter/Debug.xcconfig ios/Flutter/Release.xcconfig ios/Runner/AppDelegate.swift ios/Runner/Runner-Bridging-Header.h +ios/Runner/GoogleService-Info.plist ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png @@ -110,6 +118,7 @@ macos/.gitignore macos/Flutter/Flutter-Debug.xcconfig macos/Flutter/Flutter-Release.xcconfig macos/Flutter/GeneratedPluginRegistrant.swift +macos/Flutter/ephemeral/ macos/Runner/AppDelegate.swift macos/Runner/DebugProfile.entitlements macos/Runner/MainFlutterWindow.swift diff --git a/README.md b/README.md index be46f067..e937aa70 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Step 7: Additional Auth flow items * Phone Auth on Web has a ReCaptcha already [https://firebase.flutter.dev/docs/auth/phone/]. Tried to use that library but it is very cryptic. * Use the following instead [https://stackoverflow.com/questions/60675575/how-to-implement-recaptcha-into-a-flutter-app] or [https://medium.com/cloudcraftz/securing-your-flutter-web-app-with-google-recaptcha-b556c567f409] or [https://pub.dev/packages/g_recaptcha_v3] 3. TODO: Ensure Reset Password has Email verification -4. TODO: Add Phone verification [https://firebase.google.com/docs/auth/flutter/phone-auth] +4. Added Phone verification [https://firebase.google.com/docs/auth/flutter/phone-auth] * See [https://github.com/firebase/flutterfire/issues/4189]. 5. TODO: Add 2FA with SMS Pin. This screen is available in the Flutter Fire package diff --git a/assets/icons/google.png b/assets/icons/google.png new file mode 100644 index 00000000..494acede Binary files /dev/null and b/assets/icons/google.png differ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index fc7f1dd4..3d1ee42c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -52,4 +52,20 @@ NSPhotoLibraryUsageDescription Allow access to photo library + + +CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + + + com.googleusercontent.apps.260821266676-n1dskq31gq05s8egsuemfl2slek3vd9b + + + + diff --git a/lib/app/modules/login/views/custom_login.dart b/lib/app/modules/login/views/custom_login.dart new file mode 100644 index 00000000..3184c099 --- /dev/null +++ b/lib/app/modules/login/views/custom_login.dart @@ -0,0 +1,275 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../../models/screens.dart'; +import '../../../../services/auth_service.dart'; + +class CustomSignIn extends StatelessWidget { + const CustomSignIn({super.key}); + + @override + Widget build(BuildContext context) { + final height = MediaQuery.of(context).size.height; + final width = MediaQuery.of(context).size.width; + + final emailController = TextEditingController(); + final passwordController = TextEditingController(); + + return Scaffold( + backgroundColor: Colors.grey[300], + body: SingleChildScrollView( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: height * 0.02), + //logo + Image.asset( + 'assets/icons/logo.png', + height: height * 0.25, + width: width * 0.25, + ), + + SizedBox( + height: height * 0.02, + ), + + // welcome back + const Text("You were missed! Welcome Back"), + SizedBox(height: height * 0.02), + + //username textfield + InputWidget( + width: width, + emailController: emailController, + hintText: 'Email ID', + obscureText: false, + keyboardType: TextInputType.emailAddress, + ), + SizedBox(height: height * 0.02), + //password textfield + InputWidget( + width: width, + emailController: passwordController, + hintText: 'Password', + obscureText: true, + ), + + //forgot password? + Padding( + padding: EdgeInsets.only(right: width * 0.055), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + TextEditingController resetController = + TextEditingController(); + Get.defaultDialog( + title: "Reset Passsword", + content: Column( + children: [ + const Text("Enter your Email-ID"), + InputWidget( + width: width, + emailController: resetController, + hintText: "Email", + obscureText: false), + ], + ), + actions: [ + TextButton( + child: const Text("Cancel"), + onPressed: () { + Get.back(); + }, + ), + TextButton( + child: const Text('Send Reset Link'), + onPressed: () async { + try { + await AuthService().resetPassword( + email: resetController.text.trim()); + Get.snackbar('Password Reset', + 'A password reset link has been sent to your email.'); + Get.back(); // Close dialog using GetX + } catch (e) { + Get.snackbar( + 'Error', 'Failed to send reset link.'); + log("Error sending reset link: $e"); + } + }, + ), + ], + ); + }, + child: Text( + "Forgot Password?", + style: TextStyle(color: Colors.grey.shade600), + )), + ], + ), + ), + //sign in button + GestureDetector( + onTap: () { + if (emailController.text.isEmpty || + !emailController.text.contains('@')) { + Get.snackbar( + 'Invalid Email', + "Please enter a valid email id.", + ); + } else if (passwordController.text.isEmpty || + passwordController.text.length < 6) { + Get.snackbar( + 'Invalid Password', + "Please enter a password longer than 6 letters.", + ); + } else if (emailController.text.isNotEmpty && + passwordController.text.isNotEmpty) { + AuthService() + .login(emailController.text, passwordController.text); + } + }, + child: Container( + padding: EdgeInsets.symmetric(horizontal: width * 0.05), + margin: EdgeInsets.symmetric(horizontal: width * 0.1), + height: height * 0.07, + width: width * 0.2, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(10), + ), + child: const Center( + child: Text("Sign In", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 18))), + ), + ), + + SizedBox(height: height * 0.05), + + //or continue with + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: width * 0.2, + child: Divider( + thickness: 0.5, + color: Colors.grey.shade900, + )), + SizedBox( + width: width * 0.05, + ), + const Text("Or Continue with"), + SizedBox( + width: width * 0.05, + ), + SizedBox( + width: width * 0.2, + child: Divider( + thickness: 0.5, + color: Colors.grey.shade900, + )), + ], + ), + + SizedBox(height: height * 0.02), + + //google sign in + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: () => AuthService().signInwithGoogle(), + child: Container( + padding: EdgeInsets.all(width * 0.03), + margin: EdgeInsets.symmetric(horizontal: width * 0.04), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(10), + ), + child: Image.asset('assets/icons/google.png', + height: height * 0.045), + ), + ), + GestureDetector( + onTap: () => + Get.rootDelegate.toNamed(Screen.MOBILEAUTH.route), + child: Container( + padding: EdgeInsets.all(width * 0.03), + margin: EdgeInsets.symmetric(horizontal: width * 0.04), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.phone, size: height * 0.045), + ), + ), + ], + ), + + SizedBox(height: height * 0.02), + + //register now / sign up now + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('New user?'), + GestureDetector( + onTap: () { + Get.rootDelegate.toNamed(Screen.SIGNUP.route); + }, + child: const Text(' Register now', + style: TextStyle(color: Colors.blue))), + ], + ), + const SizedBox( + height: 40, + ), + ], + ), + ), + ), + ); + } +} + +class InputWidget extends StatelessWidget { + const InputWidget( + {super.key, + required this.width, + required this.emailController, + required this.hintText, + required this.obscureText, + this.keyboardType}); + + final double width; + final TextEditingController emailController; + final String hintText; + final bool obscureText; + final TextInputType? keyboardType; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: width * 0.08), + child: TextField( + controller: emailController, + decoration: InputDecoration( + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.white)), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey.shade400)), + fillColor: Colors.grey.shade200, + filled: true, + hintText: hintText), + obscureText: obscureText, + ), + ); + } +} diff --git a/lib/app/modules/login/views/custom_signUp.dart b/lib/app/modules/login/views/custom_signUp.dart new file mode 100644 index 00000000..de452068 --- /dev/null +++ b/lib/app/modules/login/views/custom_signUp.dart @@ -0,0 +1,225 @@ +// ignore_for_file: file_names + +import 'package:firebase_ui_auth/firebase_ui_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../../models/screens.dart'; +import '../../../../services/auth_service.dart'; + +class CustomSignUp extends GetView { + const CustomSignUp({super.key}); + + @override + Widget build(BuildContext context) { + final height = MediaQuery.of(context).size.height; + final width = MediaQuery.of(context).size.width; + + final emailController = TextEditingController(); + final passwordController = TextEditingController(); + final confirmpasswordController = TextEditingController(); + return Scaffold( + backgroundColor: Colors.grey[300], + body: SingleChildScrollView( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: height * 0.02), + + //logo + Image.asset( + 'assets/icons/logo.png', + height: height * 0.2, + width: width * 0.2, + ), + + SizedBox(height: height * 0.01), + + // welcome back + const Text("Welcome Aboard! Let's have a great time."), + SizedBox(height: height * 0.02), + + //username textfield + InputWidget( + width: width, + emailController: emailController, + hintText: 'Email ID', + obscureText: false, + ), + SizedBox(height: height * 0.01), + //password textfield + InputWidget( + width: width, + emailController: passwordController, + hintText: 'Password', + obscureText: true, + ), + SizedBox(height: height * 0.01), + //password textfield + InputWidget( + width: width, + emailController: confirmpasswordController, + hintText: 'Confirm Password', + obscureText: true, + ), + SizedBox(height: height * 0.03), + + //sign in button + GestureDetector( + onTap: () { + if (emailController.text.isEmpty || + !emailController.text.contains('@')) { + Get.snackbar( + 'Invalid Email', + "Please enter a valid email id.", + ); + } else if (passwordController.text.isEmpty || + passwordController.text.length < 6) { + Get.snackbar( + 'Invalid Password', + "Please enter a password longer than 6 letters.", + ); + } else if (passwordController.text == + confirmpasswordController.text) { + AuthService() + .signUp(emailController.text, passwordController.text); + } + }, + child: Container( + padding: EdgeInsets.symmetric(horizontal: width * 0.05), + margin: EdgeInsets.symmetric(horizontal: width * 0.1), + height: height * 0.07, + width: width * 0.2, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(10), + ), + child: const Center( + child: Text("Sign Up", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 18))), + ), + ), + + SizedBox(height: height * 0.05), + + //or continue with + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: width * 0.2, + child: Divider( + thickness: 0.5, + color: Colors.grey.shade900, + )), + SizedBox( + width: width * 0.05, + ), + const Text("Or Continue with"), + SizedBox( + width: width * 0.05, + ), + SizedBox( + width: width * 0.2, + child: Divider( + thickness: 0.5, + color: Colors.grey.shade900, + )), + ], + ), + + SizedBox(height: height * 0.01), + + //google sign in + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: () => AuthService().signInwithGoogle(), + child: Container( + padding: EdgeInsets.all(width * 0.03), + margin: EdgeInsets.symmetric(horizontal: width * 0.04), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(10), + ), + child: Image.asset('assets/icons/google.png', + height: height * 0.045), + ), + ), + GestureDetector( + onTap: () => + Get.rootDelegate.toNamed(Screen.MOBILEAUTH.route), + child: Container( + padding: EdgeInsets.all(width * 0.03), + margin: EdgeInsets.symmetric(horizontal: width * 0.04), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.phone, size: height * 0.045), + ), + ), + ], + ), + + SizedBox(height: height * 0.01), + + //register now / sign up now + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Exisiting user?'), + GestureDetector( + onTap: () { + Get.rootDelegate.toNamed(Screen.LOGIN.route); + // print(Get.rootDelegate.history); + }, + child: const Text(' Sign In', + style: TextStyle(color: Colors.blue))), + ], + ), + SizedBox(height: height * 0.1) + ], + ), + ), + ), + ); + } +} + +class InputWidget extends StatelessWidget { + const InputWidget( + {super.key, + required this.width, + required this.emailController, + required this.hintText, + required this.obscureText}); + + final double width; + final TextEditingController emailController; + final String hintText; + final bool obscureText; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: width * 0.08), + child: TextField( + controller: emailController, + decoration: InputDecoration( + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.white)), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey.shade400)), + fillColor: Colors.grey.shade200, + filled: true, + hintText: hintText), + obscureText: obscureText, + ), + ); + } +} diff --git a/lib/app/modules/login/views/login_view.dart b/lib/app/modules/login/views/login_view.dart index 00c3af3f..010a7cf7 100644 --- a/lib/app/modules/login/views/login_view.dart +++ b/lib/app/modules/login/views/login_view.dart @@ -1,15 +1,15 @@ -// ignore_for_file: inference_failure_on_function_invocation +// ignore_for_file: inference_failure_on_function_invocation, non_constant_identifier_names import 'package:firebase_auth/firebase_auth.dart' as fba; import 'package:firebase_ui_auth/firebase_ui_auth.dart'; -import 'package:firebase_ui_oauth_google/firebase_ui_oauth_google.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import '../../../../firebase_options.dart'; import '../../../../models/screens.dart'; import '../../../widgets/login_widgets.dart'; import '../controllers/login_controller.dart'; +import 'custom_login.dart'; +import 'custom_signUp.dart'; class LoginView extends GetView { void showReverificationButton( @@ -51,37 +51,13 @@ class LoginView extends GetView { Widget loginScreen(BuildContext context) { Widget ui; if (!controller.isLoggedIn) { - ui = !(GetPlatform.isAndroid || GetPlatform.isIOS) && controller.isRobot - ? recaptcha() - : SignInScreen( - providers: [ - GoogleProvider(clientId: DefaultFirebaseOptions.webClientId), - MyEmailAuthProvider(), - ], - showAuthActionSwitch: !controller.isRegistered, - showPasswordVisibilityToggle: true, - headerBuilder: LoginWidgets.headerBuilder, - subtitleBuilder: subtitleBuilder, - footerBuilder: (context, action) => footerBuilder( - controller.showReverificationButton, - LoginController.to.credential), - sideBuilder: LoginWidgets.sideBuilder, - actions: getActions(), - ); + if (!(GetPlatform.isAndroid || GetPlatform.isIOS) && controller.isRobot) { + ui = recaptcha(); + } else { + ui = const CustomSignIn(); + } } else if (controller.isAnon) { - ui = RegisterScreen( - providers: [ - MyEmailAuthProvider(), - ], - showAuthActionSwitch: !controller.isAnon, //if Anon only SignUp - showPasswordVisibilityToggle: true, - headerBuilder: LoginWidgets.headerBuilder, - subtitleBuilder: subtitleBuilder, - footerBuilder: (context, action) => footerBuilder( - controller.showReverificationButton, LoginController.to.credential), - sideBuilder: LoginWidgets.sideBuilder, - actions: getActions(), - ); + ui = const CustomSignUp(); } else { final thenTo = Get .rootDelegate.currentConfiguration!.currentPage!.parameters?['then']; @@ -92,6 +68,41 @@ class LoginView extends GetView { return ui; } + // RegisterScreen Register() { + // return RegisterScreen( + // providers: [ + // MyEmailAuthProvider(), + // ], + // showAuthActionSwitch: !controller.isAnon, //if Anon only SignUp + // showPasswordVisibilityToggle: true, + // headerBuilder: LoginWidgets.headerBuilder, + // subtitleBuilder: subtitleBuilder, + // footerBuilder: (context, action) => footerBuilder( + // controller.showReverificationButton, LoginController.to.credential), + // sideBuilder: LoginWidgets.sideBuilder, + // actions: getActions(), + // ); + // } + + // SignInScreen SignIn() { + // return SignInScreen( + // providers: [ + // GoogleProvider( + // clientId: + // clientID), + // MyEmailAuthProvider(), + // ], + // showAuthActionSwitch: !controller.isRegistered, + // showPasswordVisibilityToggle: true, + // headerBuilder: LoginWidgets.headerBuilder, + // subtitleBuilder: subtitleBuilder, + // footerBuilder: (context, action) => footerBuilder( + // controller.showReverificationButton, LoginController.to.credential), + // sideBuilder: LoginWidgets.sideBuilder, + // actions: getActions(), + // ); + // } + Widget recaptcha() { //TODO: Add Recaptcha return Scaffold( diff --git a/lib/app/modules/phone_auth/bindings/phone_auth_binding.dart b/lib/app/modules/phone_auth/bindings/phone_auth_binding.dart new file mode 100644 index 00000000..c3aedaaf --- /dev/null +++ b/lib/app/modules/phone_auth/bindings/phone_auth_binding.dart @@ -0,0 +1,13 @@ +import 'package:get/get.dart'; + +import '../controllers/phone_auth_controller.dart'; + + +class MobileAuthBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => MobileAuthController(), + ); + } +} diff --git a/lib/app/modules/phone_auth/controllers/phone_auth_controller.dart b/lib/app/modules/phone_auth/controllers/phone_auth_controller.dart new file mode 100644 index 00000000..b4eb4393 --- /dev/null +++ b/lib/app/modules/phone_auth/controllers/phone_auth_controller.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +// PhoneAuthController is a firebase controller name, hence that name cannot be used. +class MobileAuthController extends GetxController { + final TextEditingController phoneNumberController = TextEditingController(); + final TextEditingController otpController = TextEditingController(); + RxString verificationId = ''.obs; + RxBool codeSent = false.obs; + RxBool isLoading = false.obs; + + @override + void onClose() { + phoneNumberController.dispose(); + otpController.dispose(); + super.onClose(); + } +} diff --git a/lib/app/modules/phone_auth/views/phone_auth.dart b/lib/app/modules/phone_auth/views/phone_auth.dart new file mode 100644 index 00000000..8ba8d991 --- /dev/null +++ b/lib/app/modules/phone_auth/views/phone_auth.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:get_flutter_fire/app/modules/phone_auth/controllers/phone_auth_controller.dart'; +import 'package:get_flutter_fire/services/auth_service.dart'; + +class MobileAuth extends GetView { + const MobileAuth({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Phone Authentication'), + centerTitle: true, + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Obx( + () => controller.codeSent.value + ? OtpScreen(verificationId: controller.verificationId.value) + : const VerifyPhoneScreen(), + ), + ), + ), + ); + } +} + +class VerifyPhoneScreen extends GetView { + const VerifyPhoneScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: controller.phoneNumberController, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + hintText: 'Enter Phone Number', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + prefixIcon: const Icon(Icons.phone), + ), + ), + const SizedBox(height: 20), + Obx(() => controller.isLoading.value + ? const Center(child: CircularProgressIndicator()) + : ElevatedButton( + onPressed: () => AuthService().verifyMobileNumber( + controller.phoneNumberController.text), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text( + 'Send OTP', + style: TextStyle(fontSize: 16), + ), + )), + ], + ), + ))); + + // return Column( + // mainAxisAlignment: MainAxisAlignment.center, + // children: [ + // TextField( + // controller: controller.phoneNumberController, + // keyboardType: TextInputType.phone, + // decoration: const InputDecoration(hintText: 'Enter Phone Number'), + // ), + // const SizedBox(height: 20), + // Obx(() => controller.isLoading.value + // ? const CircularProgressIndicator() + // : ElevatedButton( + // onPressed: () => AuthService() + // .verifyMobileNumber(controller.phoneNumberController.text), + // child: const Text('Send OTP'), + // )), + // ], + // ); + } +} + +class OtpScreen extends GetView { + final String verificationId; + const OtpScreen({super.key, required this.verificationId}); + + @override + Widget build(BuildContext context) { + return Scaffold( + // appBar: AppBar( + // title: const Text('Verify OTP'), + // centerTitle: true, + // ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Enter the OTP sent to your mobile number', + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + TextField( + controller: controller.otpController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Enter OTP', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + contentPadding: + const EdgeInsets.symmetric(vertical: 15, horizontal: 10), + ), + ), + const SizedBox(height: 20), + Obx(() => controller.isLoading.value + ? const CircularProgressIndicator() + : SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => AuthService().verifyOTP( + controller.otpController.text, verificationId), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 15), + textStyle: const TextStyle(fontSize: 16), + ), + child: const Text('Verify OTP'), + ), + )), + ], + ), + ), + ), + ); + } +} diff --git a/lib/app/modules/profile/views/profile_view.dart b/lib/app/modules/profile/views/profile_view.dart index c26d11c1..cd9b8e28 100644 --- a/lib/app/modules/profile/views/profile_view.dart +++ b/lib/app/modules/profile/views/profile_view.dart @@ -6,6 +6,7 @@ import 'package:get/get.dart'; import '../../../../services/auth_service.dart'; import '../../../../models/screens.dart'; +import '../../../utils/img_constants.dart'; import '../../../widgets/change_password_dialog.dart'; import '../../../widgets/image_picker_button.dart'; import '../controllers/profile_controller.dart'; @@ -62,7 +63,7 @@ class ProfileView extends GetView { ) : Center( child: Image.asset( - 'assets/images/dash.png', + ImgConstants.dash, width: size, fit: BoxFit.contain, ), diff --git a/lib/app/modules/root/views/drawer.dart b/lib/app/modules/root/views/drawer.dart index 908d0223..ed5eb929 100644 --- a/lib/app/modules/root/views/drawer.dart +++ b/lib/app/modules/root/views/drawer.dart @@ -7,7 +7,8 @@ import '../../../../models/role.dart'; import '../../../../services/auth_service.dart'; import '../../../../models/screens.dart'; -import '../controllers/my_drawer_controller.dart'; +import '../../../../services/remote_config.dart'; +import '../../../widgets/remotely_config_obx.dart'; class DrawerWidget extends StatelessWidget { const DrawerWidget({ @@ -16,23 +17,26 @@ class DrawerWidget extends StatelessWidget { @override Widget build(BuildContext context) { - MyDrawerController controller = Get.put(MyDrawerController([]), - permanent: true); //must make true else gives error - Screen.drawer().then((v) => {controller.values.value = v}); - return Obx(() => Drawer( - //changing the shape of the drawer - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topRight: Radius.circular(0), bottomRight: Radius.circular(20)), - ), - width: 200, - child: Column( - children: drawerItems(context, controller.values), - ), - )); + return RemotelyConfigObxVal.noparam( + (data) => Drawer( + //changing the shape of the drawer + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(0), bottomRight: Radius.circular(20)), + ), + width: 200, + child: Column( + children: drawerItems(context, data), + ), + ), + List.empty().obs, + "useBottomSheetForProfileOptions", + Typer.boolean, + func: Screen.drawer, + ); } - List drawerItems(BuildContext context, Rx> values) { + List drawerItems(BuildContext context, Iterable values) { List list = [ Container( height: 100, @@ -67,7 +71,7 @@ class DrawerWidget extends StatelessWidget { } } - for (Screen screen in values.value) { + for (Screen screen in values) { list.add(ListTile( title: Text(screen.label ?? ''), onTap: () { diff --git a/lib/app/modules/root/views/root_view.dart b/lib/app/modules/root/views/root_view.dart index 2bbf228c..c7c6c0ca 100644 --- a/lib/app/modules/root/views/root_view.dart +++ b/lib/app/modules/root/views/root_view.dart @@ -5,6 +5,8 @@ import 'package:get/get.dart'; import 'package:get_flutter_fire/services/auth_service.dart'; import '../../../routes/app_pages.dart'; import '../../../../models/screens.dart'; +import '../../../utils/icon_constants.dart'; +import '../../../widgets/screen_widget.dart'; import '../controllers/root_controller.dart'; import 'drawer.dart'; @@ -31,14 +33,14 @@ class RootView extends GetView { ) : IconButton( icon: ImageIcon( - const AssetImage("icons/logo.png"), + const AssetImage(IconConstants.logo), color: Colors.grey.shade800, ), onPressed: () => AuthService.to.isLoggedInValue ? controller.openDrawer() : {Screen.HOME.doAction()}, ), - actions: topRightMenuButtons(current), + actions: ScreenWidgetExtension.topRightMenuButtons(current), // automaticallyImplyLeading: false, //removes drawer icon ), body: GetRouterOutlet( @@ -52,13 +54,4 @@ class RootView extends GetView { }, ); } - -//This could be used to add icon buttons in expanded web view instead of the context menu - List topRightMenuButtons(GetNavConfig current) { - return [ - Container( - margin: const EdgeInsets.only(right: 15), - child: Screen.LOGIN.widget(current)) - ]; //TODO add seach button - } } diff --git a/lib/app/modules/search/bindings/search_binding.dart b/lib/app/modules/search/bindings/search_binding.dart new file mode 100644 index 00000000..64cfa36f --- /dev/null +++ b/lib/app/modules/search/bindings/search_binding.dart @@ -0,0 +1,12 @@ +import 'package:get/get.dart'; + +import '../controllers/search_controller.dart'; + +class SearchBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => SearchController(), + ); + } +} diff --git a/lib/app/modules/search/controllers/search_controller.dart b/lib/app/modules/search/controllers/search_controller.dart new file mode 100644 index 00000000..f282fabb --- /dev/null +++ b/lib/app/modules/search/controllers/search_controller.dart @@ -0,0 +1,23 @@ +import 'package:get/get.dart'; + +class SearchController extends GetxController { + //TODO: Implement SearchController + + final count = 0.obs; + @override + void onInit() { + super.onInit(); + } + + @override + void onReady() { + super.onReady(); + } + + @override + void onClose() { + super.onClose(); + } + + void increment() => count.value++; +} diff --git a/lib/app/modules/search/views/search_view.dart b/lib/app/modules/search/views/search_view.dart new file mode 100644 index 00000000..f0c3c16c --- /dev/null +++ b/lib/app/modules/search/views/search_view.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart' hide SearchController; + +import 'package:get/get.dart'; + +import '../controllers/search_controller.dart'; + +class SearchView extends GetView { + const SearchView({Key? key}) : super(key: key); + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('SearchView'), + centerTitle: true, + ), + body: const Center( + child: Text( + 'SearchView is working', + style: TextStyle(fontSize: 20), + ), + ), + ); + } +} diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index 7269755d..f68c06a2 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -1,8 +1,13 @@ import 'package:flutter/material.dart'; + import 'package:get/get.dart'; +import 'package:get_flutter_fire/app/modules/login/views/custom_signUp.dart'; +import 'package:get_flutter_fire/app/modules/phone_auth/bindings/phone_auth_binding.dart'; +import 'package:get_flutter_fire/app/modules/phone_auth/views/phone_auth.dart'; import '../../models/access_level.dart'; import '../../models/role.dart'; +import '../../models/screens.dart'; import '../middleware/auth_middleware.dart'; import '../modules/cart/bindings/cart_binding.dart'; import '../modules/cart/views/cart_view.dart'; @@ -28,6 +33,8 @@ import '../modules/register/bindings/register_binding.dart'; import '../modules/register/views/register_view.dart'; import '../modules/root/bindings/root_binding.dart'; import '../modules/root/views/root_view.dart'; +import '../modules/search/bindings/search_binding.dart'; +import '../modules/search/views/search_view.dart'; import '../modules/settings/bindings/settings_binding.dart'; import '../modules/settings/views/settings_view.dart'; import '../modules/task_details/bindings/task_details_binding.dart'; @@ -36,7 +43,6 @@ import '../modules/tasks/bindings/tasks_binding.dart'; import '../modules/tasks/views/tasks_view.dart'; import '../modules/users/bindings/users_binding.dart'; import '../modules/users/views/users_view.dart'; -import '../../models/screens.dart'; part 'app_routes.dart'; part 'screen_extension.dart'; @@ -60,6 +66,14 @@ class AppPages { page: () => const LoginView(), binding: LoginBinding(), ), + Screen.SIGNUP.getPage( + page: () => const CustomSignUp(), + binding: LoginBinding(), + ), + Screen.MOBILEAUTH.getPage( + page: () => const MobileAuth(), + binding: MobileAuthBinding(), + ), Screen.REGISTER.getPage( page: () => const RegisterView(), binding: RegisterBinding(), @@ -150,5 +164,9 @@ class AppPages { ) ], ), + Screen.SEARCH.getPage( + page: () => const SearchView(), + binding: SearchBinding(), + ), ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index f3129d21..7391dcc0 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -30,6 +30,7 @@ abstract class Routes { '${Screen.LOGIN.route}?then=${Uri.encodeQueryComponent(afterSuccessfulLogin)}'; static String REGISTER_THEN(String afterSuccessfulLogin) => '${Screen.REGISTER.route}?then=${Uri.encodeQueryComponent(afterSuccessfulLogin)}'; + // static const SEARCH = _Paths.SEARCH; } // Keeping this as Get_Cli will require it. Any addition can later be added to Screen @@ -51,4 +52,5 @@ abstract class _Paths { // static const USERS = '/users'; // static const USER_PROFILE = '/:uId'; // static const MY_PRODUCTS = '/my-products'; + // static const SEARCH = '/search'; } diff --git a/lib/app/routes/screen_extension.dart b/lib/app/routes/screen_extension.dart index aaf138b0..23877c7d 100644 --- a/lib/app/routes/screen_extension.dart +++ b/lib/app/routes/screen_extension.dart @@ -110,11 +110,14 @@ extension ScreenExtension on Screen { extension RoleExtension on Role { int getCurrentIndexFromRoute(GetNavConfig? currentRoute) { - final String? currentLocation = currentRoute?.location; + final String? currentLocation = currentRoute?.uri.path; int currentIndex = 0; if (currentLocation != null) { - currentIndex = - tabs.indexWhere((tab) => currentLocation.startsWith(tab.path)); + currentIndex = tabs.indexWhere((tab) { + String parentPath = tab.parent?.path ?? ''; + String fullPath = '$parentPath${tab.path}'; + return currentLocation.startsWith(fullPath); + }); } return (currentIndex > 0) ? currentIndex : 0; } diff --git a/lib/app/utils/icon_constants.dart b/lib/app/utils/icon_constants.dart new file mode 100644 index 00000000..b3351a95 --- /dev/null +++ b/lib/app/utils/icon_constants.dart @@ -0,0 +1,5 @@ +abstract class IconConstants { + static const _assetsIcon = 'assets/icons'; + + static const logo = '$_assetsIcon/logo.png'; +} diff --git a/lib/app/utils/img_constants.dart b/lib/app/utils/img_constants.dart new file mode 100644 index 00000000..83a5c0c6 --- /dev/null +++ b/lib/app/utils/img_constants.dart @@ -0,0 +1,6 @@ +abstract class ImgConstants { + static const _assetsImg = 'assets/images'; + + static const dash = '$_assetsImg/dash.png'; + static const flutterfire = '$_assetsImg/flutterfire_300x.png'; +} diff --git a/lib/app/widgets/login_widgets.dart b/lib/app/widgets/login_widgets.dart index b8f2d8c1..63162b44 100644 --- a/lib/app/widgets/login_widgets.dart +++ b/lib/app/widgets/login_widgets.dart @@ -6,7 +6,9 @@ import 'package:get/get.dart'; import '../../services/auth_service.dart'; import '../../models/screens.dart'; import '../../services/remote_config.dart'; +import '../utils/img_constants.dart'; import 'menu_sheet_button.dart'; +import 'remotely_config_obx.dart'; class LoginWidgets { static Widget headerBuilder(context, constraints, shrinkOffset) { @@ -14,7 +16,7 @@ class LoginWidgets { padding: const EdgeInsets.all(20), child: AspectRatio( aspectRatio: 1, - child: Image.asset('assets/images/flutterfire_300x.png'), + child: Image.asset(ImgConstants.flutterfire), ), ); } @@ -38,7 +40,7 @@ class LoginWidgets { padding: const EdgeInsets.all(20), child: AspectRatio( aspectRatio: 1, - child: Image.asset('assets/images/flutterfire_300x.png'), + child: Image.asset(ImgConstants.flutterfire), ), ); } @@ -56,31 +58,36 @@ class LoginBottomSheetToggle extends MenuSheetButton { @override Icon? get icon => (AuthService.to.isLoggedInValue) - ? values.length == 1 + ? values.length <= 1 ? const Icon(Icons.logout) : const Icon(Icons.menu) : const Icon(Icons.login); @override String? get label => (AuthService.to.isLoggedInValue) - ? values.length == 1 + ? values.length <= 1 ? 'Logout' : 'Click for Options' : 'Login'; + @override + void buttonPressed(Iterable values) async => values.isEmpty + ? callbackFunc(await Screen.LOGOUT.doAction()) + : super.buttonPressed(values); + @override Widget build(BuildContext context) { - MenuItemsController controller = Get.put( - MenuItemsController([]), - permanent: true); //must make true else gives error - Screen.sheet(null).then((val) { - controller.values.value = val; - }); - RemoteConfig.instance.then((ins) => - ins.addUseBottomSheetForProfileOptionsListener((val) async => - {controller.values.value = await Screen.sheet(null)})); + MenuItemsController controller = + MenuItemsController(const Iterable.empty()); return Obx(() => (AuthService.to.isLoggedInValue) - ? builder(context, vals: controller.values.value) + ? RemotelyConfigObx( + () => builder(context, vals: controller.values.value), + controller, + Screen.sheet, + Screen.NONE, + "useBottomSheetForProfileOptions", + Typer.boolean, + ) : !(current.currentPage!.name == Screen.LOGIN.path) ? IconButton( onPressed: () async { diff --git a/lib/app/widgets/menu_sheet_button.dart b/lib/app/widgets/menu_sheet_button.dart index abd3873e..31649e4d 100644 --- a/lib/app/widgets/menu_sheet_button.dart +++ b/lib/app/widgets/menu_sheet_button.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../../models/action_enum.dart'; +import 'remotely_config_obx.dart'; -class MenuItemsController extends GetxController { - MenuItemsController(Iterable iter) : values = Rx>(iter); - - final Rx> values; +class MenuItemsController + extends RemoteConfigController> { + MenuItemsController(super.iter); } class MenuSheetButton extends StatelessWidget { @@ -65,7 +65,7 @@ class MenuSheetButton extends StatelessWidget { //This should be a modal bottom sheet if on Mobile (See https://mercyjemosop.medium.com/select-and-upload-images-to-firebase-storage-flutter-6fac855970a9) Widget builder(BuildContext context, {Iterable? vals}) { Iterable values = vals ?? values_!; - return values.length == 1 || + return values.length <= 1 || Get.mediaQuery.orientation == Orientation.portrait // : Get.context!.isPortrait ? (icon != null diff --git a/lib/app/widgets/remotely_config_obx.dart b/lib/app/widgets/remotely_config_obx.dart new file mode 100644 index 00000000..684503c0 --- /dev/null +++ b/lib/app/widgets/remotely_config_obx.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../services/remote_config.dart'; + +class RemoteConfigController extends GetxController { + RemoteConfigController(L iter) : values = Rx(iter); + final Rx values; +} + +class RemotelyConfigObx>> + extends ObxWidget { + final Widget Function() builder; + final X param; + final C controller; + final String config; + final Typer configType; + final Future Function(X) func; + + RemotelyConfigObx(this.builder, this.controller, this.func, this.param, + this.config, this.configType, + {super.key}) { + Get.put(controller, permanent: true); //must make true else gives error + func(param).then((v) { + controller.values.value = v; + }); + RemoteConfig.instance.then((ins) => ins.addListener(config, configType, + (val) async => {controller.values.value = await func(param)})); + } + + @override + Widget build() => builder(); +} + +class RemotelyConfigObxVal extends ObxWidget { + final Widget Function(T) builder; + final T data; + final String config; + final Typer configType; + + RemotelyConfigObxVal(this.builder, this.data, this.config, this.configType, + {super.key, required Future Function(X) func, required X param}) { + func(param).then((v) => {data.value = v}); + RemoteConfig.instance.then((ins) => ins.addListener( + config, configType, (val) async => {data.value = await func(param)})); + } + + RemotelyConfigObxVal.noparam( + this.builder, this.data, this.config, this.configType, + {super.key, required Future Function() func}) { + func().then((v) => {data.value = v}); + RemoteConfig.instance.then((ins) => ins.addListener( + config, configType, (val) async => {data.value = await func()})); + } + + @override + Widget build() => builder(data); +} diff --git a/lib/app/widgets/screen_widget.dart b/lib/app/widgets/screen_widget.dart index d80c9275..438f91e5 100644 --- a/lib/app/widgets/screen_widget.dart +++ b/lib/app/widgets/screen_widget.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import '../routes/app_pages.dart'; import '../../models/role.dart'; import '../../models/screens.dart'; +import 'login_widgets.dart'; class ScreenWidget extends StatelessWidget { final Widget body; @@ -73,3 +74,31 @@ class ScreenWidget extends StatelessWidget { return null; //TODO multi fab button on press } } + +extension ScreenWidgetExtension on Screen { + Widget? widget(GetNavConfig current) { + //those with accessor == widget must be handled here + switch (this) { + case Screen.SEARCH: + return IconButton(onPressed: () => {}, icon: Icon(icon)); + case Screen.LOGIN: + return LoginBottomSheetToggle(current); + case Screen.LOGOUT: + return LoginBottomSheetToggle(current); + default: + } + return null; + } + +//This could be used to add icon buttons in expanded web view instead of the context menu + static List topRightMenuButtons(GetNavConfig current) { + List widgets = []; + for (var screen in Screen.topRightMenu()) { + widgets.add(Container( + margin: const EdgeInsets.only(right: 15), + child: screen.widget(current))); + } + + return widgets; //This will return empty. We need a Obx + } +} diff --git a/lib/app/widgets/search_bar_button.dart b/lib/app/widgets/search_bar_button.dart new file mode 100644 index 00000000..14f60d37 --- /dev/null +++ b/lib/app/widgets/search_bar_button.dart @@ -0,0 +1,3 @@ +// This uses Remote Config to know where to locate the search button +// If on top, then it expands to title area on press in mobiles and is already expanded in web +// If on Nav Bar, the it initiates a SearchAnchor as a bottomsheet in mobile and like a drawer/nav column in web \ No newline at end of file diff --git a/lib/models/access_level.dart b/lib/models/access_level.dart index a7b89742..20b79252 100644 --- a/lib/models/access_level.dart +++ b/lib/models/access_level.dart @@ -1,7 +1,7 @@ enum AccessLevel { + notAuthed, // used for login screens public, //available without any login guest, //available with guest login - notAuthed, // used for login screens authenticated, //available on login roleBased, //available on login and with allowed roles masked, //available in a partly masked manner based on role diff --git a/lib/models/screens.dart b/lib/models/screens.dart index 24dee39f..601f4940 100644 --- a/lib/models/screens.dart +++ b/lib/models/screens.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import '../app/widgets/login_widgets.dart'; import '../services/remote_config.dart'; import 'action_enum.dart'; import 'access_level.dart'; @@ -8,9 +7,13 @@ import '../../services/auth_service.dart'; enum AccessedVia { auto, - widget, //example: top right button - navigator, //bottom nav. can be linked to drawer items //handled in ScreenWidget - drawer, //creates nav tree //handled in RootView + widget, + topRight, + topCenter, + topLeft, + topBar, //bar below the main top menu bar + navigator, //bottom nav. can be linked to drawer items. left strip in expanded web //handled in ScreenWidget + drawer, //creates nav tree. persistant in expanded web and linked with nav icons //handled in RootView bottomSheet, //context menu for web handled via the Button that calls the sheet fab, //handled in ScreenWidget singleTap, //when an item of a list is clicked @@ -18,6 +21,7 @@ enum AccessedVia { } enum Screen implements ActionEnum { + NONE.none(), //null HOME('/home', icon: Icons.home, label: "Home", @@ -37,7 +41,11 @@ enum Screen implements ActionEnum { parent: HOME), PRODUCT_DETAILS('/:productId', accessLevel: AccessLevel.public, parent: PRODUCTS), - LOGIN('/login', + SIGNUP('/signUp', + icon: Icons.login, + accessor_: AccessedVia.widget, + accessLevel: AccessLevel.notAuthed), + MOBILEAUTH('/mobileAuth', icon: Icons.login, accessor_: AccessedVia.widget, accessLevel: AccessLevel.notAuthed), @@ -46,13 +54,13 @@ enum Screen implements ActionEnum { label: "Profile", accessor_: AccessedVia.drawer, accessLevel: AccessLevel.authenticated, - remoteConfig: true), + remoteConfig: "useBottomSheetForProfileOptions"), SETTINGS('/settings', icon: Icons.settings, label: "Settings", accessor_: AccessedVia.drawer, accessLevel: AccessLevel.authenticated, - remoteConfig: true), + remoteConfig: "useBottomSheetForProfileOptions"), CART('/cart', icon: Icons.trolley, label: "Cart", @@ -96,20 +104,40 @@ enum Screen implements ActionEnum { accessLevel: AccessLevel.roleBased), MY_PRODUCT_DETAILS('/:productId', parent: MY_PRODUCTS, accessLevel: AccessLevel.roleBased), + SEARCH('/search', + icon: Icons.search, + label: "Search", + accessor_: AccessedVia.topRight, + remoteConfig: "showSearchBarOnTop", + accessLevel: AccessLevel.public), LOGOUT('/login', icon: Icons.logout, label: "Logout", - accessor_: AccessedVia.bottomSheet, + accessor_: AccessedVia.topRight, + remoteConfig: "useBottomSheetForProfileOptions", accessLevel: AccessLevel.authenticated), + LOGIN('/login', + icon: Icons.login, + accessor_: AccessedVia.topRight, + accessLevel: AccessLevel.notAuthed), ; const Screen(this.path, {this.icon, this.label, - this.parent, + this.parent = Screen.NONE, this.accessor_ = AccessedVia.singleTap, this.accessLevel = AccessLevel.authenticated, - this.remoteConfig = false}); + this.remoteConfig}); + + const Screen.none() + : path = '', + icon = null, + label = null, + parent = null, + accessor_ = AccessedVia.singleTap, + accessLevel = AccessLevel.authenticated, + remoteConfig = null; @override final IconData? icon; @@ -121,10 +149,10 @@ enum Screen implements ActionEnum { final Screen? parent; final AccessLevel accessLevel; //if false it is role based. true means allowed for all - final bool remoteConfig; + final String? remoteConfig; Future get accessor async { - if (remoteConfig && + if (remoteConfig == "useBottomSheetForProfileOptions" && (await RemoteConfig.instance).useBottomSheetForProfileOptions()) { return AccessedVia.bottomSheet; } @@ -164,6 +192,12 @@ enum Screen implements ActionEnum { return list; } + static Iterable topRightMenu() { + return Screen.values.where((Screen screen) => + screen.accessor_ == AccessedVia.topRight && + AuthService.to.accessLevel.index >= screen.accessLevel.index); + } + @override Future doAction() async { if (this == LOGOUT) { @@ -171,7 +205,4 @@ enum Screen implements ActionEnum { } Get.rootDelegate.toNamed(route); } - - Widget? widget(GetNavConfig current) => - (this == LOGIN) ? LoginBottomSheetToggle(current) : null; } diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 8bf72aaa..e2f261c6 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -1,10 +1,16 @@ // ignore_for_file: avoid_print +import 'dart:developer'; + +import 'package:cloud_functions/cloud_functions.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_ui_auth/firebase_ui_auth.dart' as fbui; import 'package:firebase_ui_localizations/firebase_ui_localizations.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:get_flutter_fire/app/modules/phone_auth/controllers/phone_auth_controller.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:get_flutter_fire/models/access_level.dart'; import '../models/screens.dart'; import '../constants.dart'; @@ -26,17 +32,30 @@ class AuthService extends GetxService { @override onInit() { super.onInit(); - if (useEmulator) _auth.useAuthEmulator(emulatorHost, 9099); + if (useEmulator) { + _auth.useAuthEmulator(emulatorHost, 9099); + } _firebaseUser.bindStream(_auth.authStateChanges()); _auth.authStateChanges().listen((User? user) { if (user != null) { user.getIdTokenResult().then((token) { _userRole.value = Role.fromString(token.claims?["role"]); }); + Get.rootDelegate.offNamed(Screen.HOME.route); + } else if (user == null) { + Get.rootDelegate.offNamed(Screen.LOGIN.route); } }); } + AccessLevel get accessLevel => user != null + ? user!.isAnonymous + ? _userRole.value.index > Role.buyer.index + ? AccessLevel.roleBased + : AccessLevel.authenticated + : AccessLevel.guest + : AccessLevel.public; + bool get isEmailVerified => user != null && (user!.email == null || user!.emailVerified); @@ -52,8 +71,119 @@ class AuthService extends GetxService { ? (user!.displayName ?? user!.email) : 'Guest'; - void login() { - // this is not needed as we are using Firebase UI for the login part +// Function to handle google sign in. +//* Can use silent sign in to make the flow smoother on the web. + final GoogleSignIn _googleSignIn = + GoogleSignIn(clientId: const String.fromEnvironment('CLIENT_ID')); + Future signInwithGoogle() async { + try { + final GoogleSignInAccount? googleSignInAccount = + await _googleSignIn.signIn(); + final GoogleSignInAuthentication googleSignInAuthentication = + await googleSignInAccount!.authentication; + final AuthCredential credential = GoogleAuthProvider.credential( + accessToken: googleSignInAuthentication.accessToken, + idToken: googleSignInAuthentication.idToken, + ); + await _auth.signInWithCredential(credential); + } on FirebaseAuthException catch (e) { + print(e.message); + rethrow; + } + return null; + } + + // Functions to handle phone authentication. + // Function to verify the phone number. On android this tries to read the code directly without the user having to manually enter the code. + Future verifyMobileNumber(String phoneNumber) async { + await _auth.verifyPhoneNumber( + phoneNumber: phoneNumber, + verificationCompleted: (PhoneAuthCredential credential) async { + await _auth.signInWithCredential(credential); + }, + verificationFailed: (FirebaseAuthException e) { + String errorMessage = 'Verification failed'; + switch (e.code) { + case 'invalid-verification-code': + errorMessage = 'Invalid SMS code.'; + break; + case 'expired-verification-code': + errorMessage = 'SMS code has expired.'; + break; + default: + errorMessage = 'An unknown error occurred. Please try again later.'; + } + Get.snackbar(errorMessage, ''); + }, + codeSent: (String verificationId, int? resendToken) { + print("Code sent"); + Get.find().codeSent.value = true; + Get.find().verificationId.value = verificationId; + }, + codeAutoRetrievalTimeout: (String verificationId) {}, + timeout: const Duration(seconds: 60), + ); + } + + // Verify the OTP. + Future verifyOTP(String smsCode, String verificationId) async { + try { + PhoneAuthCredential credential = PhoneAuthProvider.credential( + verificationId: verificationId, smsCode: smsCode); + await _auth.signInWithCredential(credential); + print("Done"); + } catch (e) { + print(e.toString()); + } + } + + Future signUp(String email, String password) async { + try { + final credential = + await FirebaseAuth.instance.createUserWithEmailAndPassword( + email: email, + password: password, + ); + } on FirebaseAuthException catch (e) { + if (e.code == 'weak-password') { + print('The password provided is too weak.'); + } else if (e.code == 'email-already-in-use') { + print('The account already exists for that email.'); + } + } catch (e) { + print(e); + } + } + + Future login(String email, String password) async { + // isLoading.value = true; + try { + await _auth.signInWithEmailAndPassword(email: email, password: password); + } on FirebaseAuthException catch (e) { + print(e); + String errorMessage; + + switch (e.code) { + case 'user-not-found': + errorMessage = 'No user found for that email.'; + break; + case 'wrong-password': + errorMessage = 'Wrong password provided for that user.'; + break; + case 'invalid-email': + errorMessage = 'The email address is badly formatted.'; + break; + default: + errorMessage = + 'An unknown error occurred. Please try again later. ${e.toString()}'; + } + Get.snackbar('Login Error', errorMessage); + } catch (error) { + Get.snackbar('Error', 'An unexpected error occurred. Please try again.'); + print("Error during login: ${error.toString()}"); + } finally { + // isLoading.value = false; + } } void sendVerificationMail({EmailAuthCredential? emailAuth}) async { @@ -116,6 +246,36 @@ class AuthService extends GetxService { .offAndToNamed(thenTo ?? Screen.PROFILE.route); //Profile has the forms } + Future signup(String email, String password) async { + try { + UserCredential userCredential = + await _auth.createUserWithEmailAndPassword( + email: email, + password: password, + ); + print("User created"); + HttpsCallableResult result = + await FirebaseFunctions.instance.httpsCallable('addUserRole').call({ + 'email': email, + }); + + print("Result: $result"); + + // Optionally send verification email + if (true) { + await userCredential.user!.sendEmailVerification(); + } + + registered.value = true; + // Navigate to profile or other destination + final thenTo = Get + .rootDelegate.currentConfiguration!.currentPage!.parameters?['then']; + Get.rootDelegate.offAndToNamed(thenTo ?? Screen.PROFILE.route); + } catch (e) { + Get.snackbar('Registration Error', 'Failed to register: $e'); + } + } + void logout() { _auth.signOut(); if (isAnon) _auth.currentUser?.delete(); @@ -181,6 +341,15 @@ class AuthService extends GetxService { }; }; } + + resetPassword({required String email}) { + try { + _auth.sendPasswordResetEmail(email: email); + } catch (e) { + log("Error sending reset link: $e"); + rethrow; + } + } } class MyCredential extends AuthCredential { diff --git a/lib/services/remote_config.dart b/lib/services/remote_config.dart index 5d1145a5..cb2a8928 100644 --- a/lib/services/remote_config.dart +++ b/lib/services/remote_config.dart @@ -13,7 +13,6 @@ class RemoteConfig { } final FirebaseRemoteConfig _remoteConfig = FirebaseRemoteConfig.instance; - final List listeners = []; Future init() async { await _remoteConfig.setConfigSettings(RemoteConfigSettings( @@ -68,11 +67,4 @@ class RemoteConfig { bool showSearchBarOnTop() { return _remoteConfig.getBool("showSearchBarOnTop"); } - - void addUseBottomSheetForProfileOptionsListener(listener) { - addListener("useBottomSheetForProfileOptions", Typer.boolean, listener); - if (!listeners.contains(listener)) { - listeners.add(listener); - } - } } diff --git a/pubspec.lock b/pubspec.lock index 877fc75e..38a4c2f5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + cloud_functions: + dependency: "direct main" + description: + name: cloud_functions + sha256: ddec68a2fbee603527c009bb20c6bd071559dfa87fda55d9d92052d1ebff5377 + url: "https://pub.dev" + source: hosted + version: "4.7.6" + cloud_functions_platform_interface: + dependency: transitive + description: + name: cloud_functions_platform_interface + sha256: "0c6fca0e64fc2d3a3834d39f99b0ee6f76d96f94bb5acf4593af891df914d175" + url: "https://pub.dev" + source: hosted + version: "5.5.28" + cloud_functions_web: + dependency: transitive + description: + name: cloud_functions_web + sha256: af536e7c7223c64250c6cc384dc553d76bbacc9b9127389df0c654887f203911 + url: "https://pub.dev" + source: hosted + version: "4.9.6" collection: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2909a374..9bf993cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,13 +2,13 @@ name: get_flutter_fire version: 1.0.0+1 publish_to: none description: Boilerplate for Flutter with GetX, showing sample utilization of Firebase capabilities -environment: - sdk: '>=3.3.4 <4.0.0' +environment: + sdk: ">=3.3.4 <4.0.0" -dependencies: +dependencies: cupertino_icons: ^1.0.6 get: 4.6.6 - flutter: + flutter: sdk: flutter firebase_core: ^2.31.0 firebase_ui_auth: ^1.14.0 @@ -24,20 +24,20 @@ dependencies: firebase_ui_localizations: ^1.12.0 firebase_remote_config: ^4.4.7 firebase_analytics: ^10.10.7 + cloud_functions: ^4.7.6 -dev_dependencies: +dev_dependencies: flutter_lints: 3.0.2 - flutter_test: + flutter_test: sdk: flutter -flutter: +flutter: fonts: - family: SocialIcons fonts: - asset: packages/firebase_ui_auth/fonts/SocialIcons.ttf assets: - - assets/images/flutterfire_300x.png - - assets/images/dash.png - - assets/icons/logo.png + - assets/ + - assets/images/ + - assets/icons/ uses-material-design: true -