diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c503614..585a8fc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -33,8 +33,8 @@ android { applicationId = "com.payfrit.app" minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion - versionCode = 1 - versionName = "1.0.0" + versionCode = flutter.versionCode + versionName = flutter.versionName } signingConfigs { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 34ab1e1..8e53312 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -10,14 +11,16 @@ - + - - + - + + + + get routes => { splash: (_) => const SplashScreen(), @@ -50,5 +52,6 @@ class AppRoutes { addressList: (_) => const AddressListScreen(), addressEdit: (_) => const AddressEditScreen(), about: (_) => const AboutScreen(), + signup: (_) => const SignupScreen(), }; } diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart index 1b76d57..b52ac11 100644 --- a/lib/screens/account_screen.dart +++ b/lib/screens/account_screen.dart @@ -146,9 +146,11 @@ class _AccountScreenState extends State { }); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Avatar updated! Servers can now recognize you.'), - backgroundColor: Colors.green, + SnackBar( + content: const Text('Avatar updated! Servers can now recognize you.', style: TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } @@ -158,8 +160,10 @@ class _AccountScreenState extends State { setState(() => _isUploadingAvatar = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to upload avatar: $e'), - backgroundColor: Colors.red, + content: Text('Failed to upload avatar: $e', style: const TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } @@ -211,7 +215,12 @@ class _AccountScreenState extends State { if (mounted) { setState(() => _isLoggingOut = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error signing out: $e')), + SnackBar( + content: Text('Error signing out: $e', style: const TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), + ), ); } } diff --git a/lib/screens/address_edit_screen.dart b/lib/screens/address_edit_screen.dart index 030b17b..7afffde 100644 --- a/lib/screens/address_edit_screen.dart +++ b/lib/screens/address_edit_screen.dart @@ -99,8 +99,10 @@ class _AddressEditScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(_isEditing ? 'Address updated' : 'Address added'), - backgroundColor: Colors.green, + content: Text(_isEditing ? 'Address updated' : 'Address added', style: const TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); Navigator.pop(context, true); @@ -111,8 +113,10 @@ class _AddressEditScreenState extends State { setState(() => _isSaving = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to save: $e'), - backgroundColor: Colors.red, + content: Text('Failed to save: $e', style: const TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } diff --git a/lib/screens/address_list_screen.dart b/lib/screens/address_list_screen.dart index 667a869..42fc95a 100644 --- a/lib/screens/address_list_screen.dart +++ b/lib/screens/address_list_screen.dart @@ -70,9 +70,11 @@ class _AddressListScreenState extends State { await Api.deleteDeliveryAddress(address.addressId); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Address deleted'), - backgroundColor: Colors.green, + SnackBar( + content: const Text('Address deleted', style: TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); _loadAddresses(); @@ -81,8 +83,10 @@ class _AddressListScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to delete: $e'), - backgroundColor: Colors.red, + content: Text('Failed to delete: $e', style: const TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } @@ -95,8 +99,10 @@ class _AddressListScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('"${address.label}" set as default'), - backgroundColor: Colors.green, + content: Text('"${address.label}" set as default', style: const TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); _loadAddresses(); @@ -105,8 +111,10 @@ class _AddressListScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to set default: $e'), - backgroundColor: Colors.red, + content: Text('Failed to set default: $e', style: const TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } diff --git a/lib/screens/cart_view_screen.dart b/lib/screens/cart_view_screen.dart index b74101a..836a5a2 100644 --- a/lib/screens/cart_view_screen.dart +++ b/lib/screens/cart_view_screen.dart @@ -308,9 +308,11 @@ class _CartViewScreenState extends State { // Ensure order type is selected for delivery/takeaway orders if (_needsOrderTypeSelection && _selectedOrderType == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Please select Delivery or Takeaway"), - backgroundColor: Colors.orange, + SnackBar( + content: const Text("Please select Delivery or Takeaway", style: TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); return; @@ -319,9 +321,11 @@ class _CartViewScreenState extends State { // Ensure delivery address is selected for delivery orders if (_needsDeliveryAddress && _selectedAddress == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Please select a delivery address"), - backgroundColor: Colors.orange, + SnackBar( + content: const Text("Please select a delivery address", style: TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); return; @@ -359,12 +363,12 @@ class _CartViewScreenState extends State { SnackBar( content: Row( children: [ - Icon(Icons.error, color: Colors.white), - SizedBox(width: 8), - Expanded(child: Text(paymentResult.error ?? 'Payment failed')), + const Icon(Icons.error, color: Colors.black), + const SizedBox(width: 8), + Expanded(child: Text(paymentResult.error ?? 'Payment failed', style: const TextStyle(color: Colors.black))), ], ), - backgroundColor: Colors.red, + backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), @@ -426,16 +430,17 @@ class _CartViewScreenState extends State { SnackBar( content: const Row( children: [ - Icon(Icons.check_circle, color: Colors.white), + Icon(Icons.check_circle, color: Colors.black), SizedBox(width: 8), Expanded( child: Text( "Payment successful! Order placed. You'll receive notifications as your order is prepared.", + style: TextStyle(color: Colors.black), ), ), ], ), - backgroundColor: Colors.green, + backgroundColor: const Color(0xFF90EE90), duration: const Duration(seconds: 5), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), @@ -452,12 +457,12 @@ class _CartViewScreenState extends State { SnackBar( content: Row( children: [ - const Icon(Icons.error, color: Colors.white), + const Icon(Icons.error, color: Colors.black), const SizedBox(width: 8), - Expanded(child: Text('Error: ${e.toString()}')), + Expanded(child: Text('Error: ${e.toString()}', style: const TextStyle(color: Colors.black))), ], ), - backgroundColor: Colors.red, + backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), @@ -1149,9 +1154,11 @@ class _CartViewScreenState extends State { cityController.text.trim().isEmpty || zipController.text.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Please fill in all required fields"), - backgroundColor: Colors.orange, + SnackBar( + content: const Text("Please fill in all required fields", style: TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); return; @@ -1179,8 +1186,10 @@ class _CartViewScreenState extends State { if (ctx.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text("Error: ${e.toString()}"), - backgroundColor: Colors.red, + content: Text("Error: ${e.toString()}", style: const TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } diff --git a/lib/screens/group_order_invite_screen.dart b/lib/screens/group_order_invite_screen.dart index c1439ee..4e87763 100644 --- a/lib/screens/group_order_invite_screen.dart +++ b/lib/screens/group_order_invite_screen.dart @@ -70,8 +70,10 @@ class _GroupOrderInviteScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Invitation sent to ${user.name}'), - backgroundColor: Colors.green, + content: Text('Invitation sent to ${user.name}', style: const TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 705abb2..ce5ed02 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -186,23 +186,13 @@ class _LoginScreenState extends State { ), const SizedBox(height: 16), TextButton( - onPressed: _isLoading ? null : () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( - children: [ - Icon(Icons.info, color: Colors.white), - SizedBox(width: 8), - Text("Registration not yet implemented"), - ], - ), - backgroundColor: Colors.blue, - behavior: SnackBarBehavior.floating, - margin: EdgeInsets.only(bottom: 80, left: 16, right: 16), - ), - ); - }, - child: const Text("Don't have an account? Register"), + onPressed: _isLoading + ? null + : () { + Navigator.of(context) + .pushReplacementNamed(AppRoutes.signup); + }, + child: const Text("Don't have an account? Sign Up"), ), ], ), diff --git a/lib/screens/menu_browse_screen.dart b/lib/screens/menu_browse_screen.dart index 1e7b4de..ffeaf3b 100644 --- a/lib/screens/menu_browse_screen.dart +++ b/lib/screens/menu_browse_screen.dart @@ -765,17 +765,17 @@ class _MenuBrowseScreenState extends State { if (_businessId == null || _servicePointId == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( + SnackBar( + content: const Row( children: [ - Icon(Icons.warning, color: Colors.white), + Icon(Icons.warning, color: Colors.black), SizedBox(width: 8), - Text("Missing required information"), + Text("Missing required information", style: TextStyle(color: Colors.black)), ], ), - backgroundColor: Colors.orange, + backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, - margin: EdgeInsets.only(bottom: 80, left: 16, right: 16), + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); return; @@ -853,12 +853,12 @@ class _MenuBrowseScreenState extends State { SnackBar( content: Row( children: [ - const Icon(Icons.check_circle, color: Colors.white), + const Icon(Icons.check_circle, color: Colors.black), const SizedBox(width: 8), - Expanded(child: Text(message)), + Expanded(child: Text(message, style: const TextStyle(color: Colors.black))), ], ), - backgroundColor: Colors.green, + backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), @@ -870,12 +870,12 @@ class _MenuBrowseScreenState extends State { SnackBar( content: Row( children: [ - const Icon(Icons.error, color: Colors.white), + const Icon(Icons.error, color: Colors.black), const SizedBox(width: 8), - Expanded(child: Text("Error adding to cart: $e")), + Expanded(child: Text("Error adding to cart: $e", style: const TextStyle(color: Colors.black))), ], ), - backgroundColor: Colors.red, + backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), diff --git a/lib/screens/profile_settings_screen.dart b/lib/screens/profile_settings_screen.dart index 60a078e..6597a7e 100644 --- a/lib/screens/profile_settings_screen.dart +++ b/lib/screens/profile_settings_screen.dart @@ -78,9 +78,11 @@ class _ProfileSettingsScreenState extends State { }); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Profile updated'), - backgroundColor: Colors.green, + SnackBar( + content: const Text('Profile updated', style: TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); @@ -92,8 +94,10 @@ class _ProfileSettingsScreenState extends State { setState(() => _isSaving = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to save: $e'), - backgroundColor: Colors.red, + content: Text('Failed to save: $e', style: const TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } diff --git a/lib/screens/signup_screen.dart b/lib/screens/signup_screen.dart new file mode 100644 index 0000000..6283d88 --- /dev/null +++ b/lib/screens/signup_screen.dart @@ -0,0 +1,596 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:provider/provider.dart"; + +import "../app/app_router.dart"; +import "../app/app_state.dart"; +import "../services/api.dart"; +import "../services/auth_storage.dart"; + +enum SignupStep { phone, otp, profile } + +class SignupScreen extends StatefulWidget { + const SignupScreen({super.key}); + + @override + State createState() => _SignupScreenState(); +} + +class _SignupScreenState extends State { + SignupStep _currentStep = SignupStep.phone; + + // Phone step + final _phoneController = TextEditingController(); + + // OTP step + final _otpController = TextEditingController(); + String _uuid = ""; + String _phone = ""; + + // Profile step + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + final _emailController = TextEditingController(); + + bool _isLoading = false; + String? _errorMessage; + int? _userId; + String? _token; + + @override + void dispose() { + _phoneController.dispose(); + _otpController.dispose(); + _firstNameController.dispose(); + _lastNameController.dispose(); + _emailController.dispose(); + super.dispose(); + } + + String _formatPhoneNumber(String input) { + // Remove all non-digits + final digits = input.replaceAll(RegExp(r'[^\d]'), ''); + // Remove leading 1 if 11 digits + if (digits.length == 11 && digits.startsWith('1')) { + return digits.substring(1); + } + return digits; + } + + Future _handleSendOtp() async { + final phone = _formatPhoneNumber(_phoneController.text); + + if (phone.length != 10) { + setState(() { + _errorMessage = "Please enter a valid 10-digit phone number"; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final response = await Api.sendOtp(phone: phone); + + if (!mounted) return; + + setState(() { + _uuid = response.uuid; + _phone = phone; + _currentStep = SignupStep.otp; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = e.toString().replaceFirst("StateError: ", ""); + _isLoading = false; + }); + } + } + + Future _handleVerifyOtp() async { + final otp = _otpController.text.trim(); + + if (otp.length != 6) { + setState(() { + _errorMessage = "Please enter the 6-digit code"; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final response = await Api.verifyOtp(uuid: _uuid, otp: otp); + + if (!mounted) return; + + _userId = response.userId; + _token = response.token; + + // Save credentials for persistent login + await AuthStorage.saveAuth( + userId: response.userId, + token: response.token, + ); + + if (response.needsProfile) { + // Go to profile step + setState(() { + _currentStep = SignupStep.profile; + _isLoading = false; + }); + } else { + // Profile already complete - go to app + _completeSignup(); + } + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = e.toString().replaceFirst("StateError: ", ""); + _isLoading = false; + }); + } + } + + Future _handleCompleteProfile() async { + final firstName = _firstNameController.text.trim(); + final lastName = _lastNameController.text.trim(); + final email = _emailController.text.trim(); + + if (firstName.isEmpty) { + setState(() => _errorMessage = "First name is required"); + return; + } + if (lastName.isEmpty) { + setState(() => _errorMessage = "Last name is required"); + return; + } + if (email.isEmpty || !email.contains("@")) { + setState(() => _errorMessage = "Please enter a valid email address"); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + await Api.completeProfile( + firstName: firstName, + lastName: lastName, + email: email, + ); + + if (!mounted) return; + + _completeSignup(); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = e.toString().replaceFirst("StateError: ", ""); + _isLoading = false; + }); + } + } + + void _completeSignup() { + final appState = context.read(); + if (_userId != null) { + appState.setUserId(_userId!); + } + + // Show success and navigate + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text("Account created! Check your email to confirm.", style: TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), + ), + ); + + // Navigate to main app + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } else { + Navigator.of(context).pushReplacementNamed(AppRoutes.splash); + } + } + + Future _handleResendOtp() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final response = await Api.resendOtp(phone: _phone); + + if (!mounted) return; + + setState(() { + _uuid = response.uuid; + _isLoading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text("New code sent!", style: TextStyle(color: Colors.black)), + backgroundColor: const Color(0xFF90EE90), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), + ), + ); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = e.toString().replaceFirst("StateError: ", ""); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_getStepTitle()), + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Progress indicator + _buildProgressIndicator(), + const SizedBox(height: 32), + + // Step content + if (_currentStep == SignupStep.phone) _buildPhoneStep(), + if (_currentStep == SignupStep.otp) _buildOtpStep(), + if (_currentStep == SignupStep.profile) _buildProfileStep(), + + // Error message + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + _buildErrorMessage(), + ], + ], + ), + ), + ), + ), + ); + } + + String _getStepTitle() { + switch (_currentStep) { + case SignupStep.phone: + return "Create Account"; + case SignupStep.otp: + return "Verify Phone"; + case SignupStep.profile: + return "Your Info"; + } + } + + Widget _buildProgressIndicator() { + return Row( + children: [ + _buildStepDot(0, _currentStep.index >= 0), + Expanded(child: _buildStepLine(_currentStep.index >= 1)), + _buildStepDot(1, _currentStep.index >= 1), + Expanded(child: _buildStepLine(_currentStep.index >= 2)), + _buildStepDot(2, _currentStep.index >= 2), + ], + ); + } + + Widget _buildStepDot(int step, bool isActive) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surfaceContainerHighest, + ), + child: Center( + child: Text( + "${step + 1}", + style: TextStyle( + color: isActive + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + + Widget _buildStepLine(bool isActive) { + return Container( + height: 2, + color: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surfaceContainerHighest, + ); + } + + Widget _buildPhoneStep() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + "PAYFRIT", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + letterSpacing: 2, + ), + ), + const SizedBox(height: 8), + const Text( + "Enter your phone number to get started", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + const SizedBox(height: 32), + TextFormField( + controller: _phoneController, + decoration: const InputDecoration( + labelText: "Phone Number", + hintText: "(555) 123-4567", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.phone), + prefixText: "+1 ", + ), + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + ], + enabled: !_isLoading, + onFieldSubmitted: (_) => _handleSendOtp(), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _isLoading ? null : _handleSendOtp, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text("Send Verification Code"), + ), + const SizedBox(height: 16), + TextButton( + onPressed: _isLoading + ? null + : () { + Navigator.of(context).pushReplacementNamed(AppRoutes.login); + }, + child: const Text("Already have an account? Login"), + ), + ], + ); + } + + Widget _buildOtpStep() { + final formattedPhone = _phone.length == 10 + ? "(${_phone.substring(0, 3)}) ${_phone.substring(3, 6)}-${_phone.substring(6)}" + : _phone; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.sms, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + "We sent a code to", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + formattedPhone, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 32), + TextFormField( + controller: _otpController, + decoration: const InputDecoration( + labelText: "Verification Code", + hintText: "123456", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.lock), + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 24, + letterSpacing: 8, + fontWeight: FontWeight.bold, + ), + enabled: !_isLoading, + onFieldSubmitted: (_) => _handleVerifyOtp(), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _isLoading ? null : _handleVerifyOtp, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text("Verify"), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: _isLoading ? null : _handleResendOtp, + child: const Text("Resend Code"), + ), + const SizedBox(width: 16), + TextButton( + onPressed: _isLoading + ? null + : () { + setState(() { + _currentStep = SignupStep.phone; + _otpController.clear(); + _errorMessage = null; + }); + }, + child: const Text("Change Number"), + ), + ], + ), + ], + ); + } + + Widget _buildProfileStep() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.person_add, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + "Almost done!", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + "Tell us a bit about yourself", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey, + ), + ), + const SizedBox(height: 32), + TextFormField( + controller: _firstNameController, + decoration: const InputDecoration( + labelText: "First Name", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + ), + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.next, + enabled: !_isLoading, + ), + const SizedBox(height: 16), + TextFormField( + controller: _lastNameController, + decoration: const InputDecoration( + labelText: "Last Name", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + ), + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.next, + enabled: !_isLoading, + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + decoration: const InputDecoration( + labelText: "Email Address", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.email), + helperText: "We'll send a confirmation email", + ), + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + enabled: !_isLoading, + onFieldSubmitted: (_) => _handleCompleteProfile(), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _isLoading ? null : _handleCompleteProfile, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text("Complete Sign Up"), + ), + ], + ); + } + + Widget _buildErrorMessage() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade300), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: Colors.red.shade700), + const SizedBox(width: 12), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: Colors.red.shade900), + ), + ), + ], + ), + ); + } +} diff --git a/lib/services/api.dart b/lib/services/api.dart index bf4b350..9633426 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -42,6 +42,49 @@ class LoginResponse { } } +class SendOtpResponse { + final String uuid; + final String message; + + const SendOtpResponse({ + required this.uuid, + required this.message, + }); + + factory SendOtpResponse.fromJson(Map json) { + return SendOtpResponse( + uuid: (json["UUID"] as String?) ?? "", + message: (json["MESSAGE"] as String?) ?? "", + ); + } +} + +class VerifyOtpResponse { + final int userId; + final String token; + final bool needsProfile; + final String userFirstName; + final bool isEmailVerified; + + const VerifyOtpResponse({ + required this.userId, + required this.token, + required this.needsProfile, + required this.userFirstName, + required this.isEmailVerified, + }); + + factory VerifyOtpResponse.fromJson(Map json) { + return VerifyOtpResponse( + userId: (json["UserID"] as num).toInt(), + token: (json["Token"] as String?) ?? "", + needsProfile: (json["NeedsProfile"] as bool?) ?? true, + userFirstName: (json["UserFirstName"] as String?) ?? "", + isEmailVerified: (json["IsEmailVerified"] as bool?) ?? false, + ); + } +} + class Api { static String? _userToken; @@ -201,6 +244,91 @@ class Api { await AuthStorage.clearAuth(); } + // ------------------------- + // Signup / OTP Verification + // ------------------------- + + /// Send OTP to phone number for signup + /// Returns UUID to use in verifyOtp + static Future sendOtp({required String phone}) async { + final raw = await _postRaw("/auth/sendOTP.cfm", {"phone": phone}); + final j = _requireJson(raw, "SendOTP"); + + if (!_ok(j)) { + final err = _err(j); + if (err == "phone_exists") { + throw StateError("This phone number already has an account. Please login instead."); + } else if (err == "invalid_phone") { + throw StateError("Please enter a valid 10-digit phone number"); + } else { + throw StateError("Failed to send code: ${j["MESSAGE"] ?? err}"); + } + } + + return SendOtpResponse.fromJson(j); + } + + /// Verify OTP and get auth token + /// If needsProfile is true, call completeProfile next + static Future verifyOtp({ + required String uuid, + required String otp, + }) async { + final raw = await _postRaw("/auth/verifyOTP.cfm", { + "uuid": uuid, + "otp": otp, + }); + final j = _requireJson(raw, "VerifyOTP"); + + if (!_ok(j)) { + final err = _err(j); + if (err == "invalid_otp") { + throw StateError("Invalid verification code. Please try again."); + } else if (err == "expired") { + throw StateError("Verification expired. Please request a new code."); + } else { + throw StateError("Verification failed: ${j["MESSAGE"] ?? err}"); + } + } + + final response = VerifyOtpResponse.fromJson(j); + + // Store token for future requests + setAuthToken(response.token); + + return response; + } + + /// Complete user profile after phone verification + static Future completeProfile({ + required String firstName, + required String lastName, + required String email, + }) async { + final raw = await _postRaw("/auth/completeProfile.cfm", { + "firstName": firstName, + "lastName": lastName, + "email": email, + }); + final j = _requireJson(raw, "CompleteProfile"); + + if (!_ok(j)) { + final err = _err(j); + if (err == "email_exists") { + throw StateError("This email is already associated with another account"); + } else if (err == "invalid_email") { + throw StateError("Please enter a valid email address"); + } else { + throw StateError("Failed to save profile: ${j["MESSAGE"] ?? err}"); + } + } + } + + /// Resend OTP to the same phone (uses existing UUID) + static Future resendOtp({required String phone}) async { + return sendOtp(phone: phone); + } + // ------------------------- // Businesses (legacy model name: Restaurant) // ------------------------- diff --git a/pubspec.yaml b/pubspec.yaml index e3e2053..e41943a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: payfrit_app description: Payfrit MVP Flutter app scaffold publish_to: "none" -version: 0.1.0+1 +version: 3.0.0+9 environment: sdk: ">=3.4.0 <4.0.0"