Version 3.0.0+9: Fix beacon scanning and SnackBar styling
- Restore FOREGROUND_SERVICE permission for beacon scanning - Remove FOREGROUND_SERVICE_LOCATION (no video required) - Update all SnackBars to Payfrit green (#90EE90) with black text - Float SnackBars with 80px bottom margin to avoid buttons - Add signup screen with OTP verification flow - Fix build.gradle.kts to use Flutter version system Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
77e3145175
commit
2522970078
14 changed files with 839 additions and 83 deletions
|
|
@ -33,8 +33,8 @@ android {
|
||||||
applicationId = "com.payfrit.app"
|
applicationId = "com.payfrit.app"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = 1
|
versionCode = flutter.versionCode
|
||||||
versionName = "1.0.0"
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
<!-- Internet permission for API calls -->
|
<!-- Internet permission for API calls -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
|
@ -10,14 +11,16 @@
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
|
||||||
<!-- Location permissions for beacon ranging -->
|
<!-- Location permissions for beacon ranging (foreground only) -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
|
||||||
|
|
||||||
<!-- Foreground service for background beacon monitoring -->
|
<!-- Allow basic foreground service (needed by beacon library for scanning) -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
|
||||||
|
<!-- Remove location-specific foreground service and background location (these require video documentation) -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" tools:node="remove" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" tools:node="remove" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="Payfrit"
|
android:label="Payfrit"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import "../screens/about_screen.dart";
|
||||||
import "../screens/address_edit_screen.dart";
|
import "../screens/address_edit_screen.dart";
|
||||||
import "../screens/address_list_screen.dart";
|
import "../screens/address_list_screen.dart";
|
||||||
import "../screens/beacon_scan_screen.dart";
|
import "../screens/beacon_scan_screen.dart";
|
||||||
|
import "../screens/signup_screen.dart";
|
||||||
import "../screens/cart_view_screen.dart";
|
import "../screens/cart_view_screen.dart";
|
||||||
import "../screens/group_order_invite_screen.dart";
|
import "../screens/group_order_invite_screen.dart";
|
||||||
import "../screens/login_screen.dart";
|
import "../screens/login_screen.dart";
|
||||||
|
|
@ -32,6 +33,7 @@ class AppRoutes {
|
||||||
static const String addressList = "/addresses";
|
static const String addressList = "/addresses";
|
||||||
static const String addressEdit = "/address-edit";
|
static const String addressEdit = "/address-edit";
|
||||||
static const String about = "/about";
|
static const String about = "/about";
|
||||||
|
static const String signup = "/signup";
|
||||||
|
|
||||||
static Map<String, WidgetBuilder> get routes => {
|
static Map<String, WidgetBuilder> get routes => {
|
||||||
splash: (_) => const SplashScreen(),
|
splash: (_) => const SplashScreen(),
|
||||||
|
|
@ -50,5 +52,6 @@ class AppRoutes {
|
||||||
addressList: (_) => const AddressListScreen(),
|
addressList: (_) => const AddressListScreen(),
|
||||||
addressEdit: (_) => const AddressEditScreen(),
|
addressEdit: (_) => const AddressEditScreen(),
|
||||||
about: (_) => const AboutScreen(),
|
about: (_) => const AboutScreen(),
|
||||||
|
signup: (_) => const SignupScreen(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,9 +146,11 @@ class _AccountScreenState extends State<AccountScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Avatar updated! Servers can now recognize you.'),
|
content: const Text('Avatar updated! Servers can now recognize you.', style: TextStyle(color: Colors.black)),
|
||||||
backgroundColor: Colors.green,
|
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<AccountScreen> {
|
||||||
setState(() => _isUploadingAvatar = false);
|
setState(() => _isUploadingAvatar = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to upload avatar: $e'),
|
content: Text('Failed to upload avatar: $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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -211,7 +215,12 @@ class _AccountScreenState extends State<AccountScreen> {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isLoggingOut = false);
|
setState(() => _isLoggingOut = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,8 +99,10 @@ class _AddressEditScreenState extends State<AddressEditScreen> {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(_isEditing ? 'Address updated' : 'Address added'),
|
content: Text(_isEditing ? 'Address updated' : 'Address added', 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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
Navigator.pop(context, true);
|
Navigator.pop(context, true);
|
||||||
|
|
@ -111,8 +113,10 @@ class _AddressEditScreenState extends State<AddressEditScreen> {
|
||||||
setState(() => _isSaving = false);
|
setState(() => _isSaving = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to save: $e'),
|
content: Text('Failed to save: $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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,11 @@ class _AddressListScreenState extends State<AddressListScreen> {
|
||||||
await Api.deleteDeliveryAddress(address.addressId);
|
await Api.deleteDeliveryAddress(address.addressId);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Address deleted'),
|
content: const Text('Address deleted', style: TextStyle(color: Colors.black)),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: const Color(0xFF90EE90),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_loadAddresses();
|
_loadAddresses();
|
||||||
|
|
@ -81,8 +83,10 @@ class _AddressListScreenState extends State<AddressListScreen> {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to delete: $e'),
|
content: Text('Failed to delete: $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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -95,8 +99,10 @@ class _AddressListScreenState extends State<AddressListScreen> {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('"${address.label}" set as default'),
|
content: Text('"${address.label}" set as default', 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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_loadAddresses();
|
_loadAddresses();
|
||||||
|
|
@ -105,8 +111,10 @@ class _AddressListScreenState extends State<AddressListScreen> {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to set default: $e'),
|
content: Text('Failed to set default: $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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -308,9 +308,11 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
// Ensure order type is selected for delivery/takeaway orders
|
// Ensure order type is selected for delivery/takeaway orders
|
||||||
if (_needsOrderTypeSelection && _selectedOrderType == null) {
|
if (_needsOrderTypeSelection && _selectedOrderType == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text("Please select Delivery or Takeaway"),
|
content: const Text("Please select Delivery or Takeaway", style: TextStyle(color: Colors.black)),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: const Color(0xFF90EE90),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
@ -319,9 +321,11 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
// Ensure delivery address is selected for delivery orders
|
// Ensure delivery address is selected for delivery orders
|
||||||
if (_needsDeliveryAddress && _selectedAddress == null) {
|
if (_needsDeliveryAddress && _selectedAddress == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text("Please select a delivery address"),
|
content: const Text("Please select a delivery address", style: TextStyle(color: Colors.black)),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: const Color(0xFF90EE90),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
@ -359,12 +363,12 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.error, color: Colors.white),
|
const Icon(Icons.error, color: Colors.black),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(child: Text(paymentResult.error ?? 'Payment failed')),
|
Expanded(child: Text(paymentResult.error ?? 'Payment failed', style: const TextStyle(color: Colors.black))),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: const Color(0xFF90EE90),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
),
|
),
|
||||||
|
|
@ -426,16 +430,17 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: const Row(
|
content: const Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.check_circle, color: Colors.white),
|
Icon(Icons.check_circle, color: Colors.black),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
"Payment successful! Order placed. You'll receive notifications as your order is prepared.",
|
"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),
|
duration: const Duration(seconds: 5),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
|
|
@ -452,12 +457,12 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error, color: Colors.white),
|
const Icon(Icons.error, color: Colors.black),
|
||||||
const SizedBox(width: 8),
|
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,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
),
|
),
|
||||||
|
|
@ -1149,9 +1154,11 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
cityController.text.trim().isEmpty ||
|
cityController.text.trim().isEmpty ||
|
||||||
zipController.text.trim().isEmpty) {
|
zipController.text.trim().isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text("Please fill in all required fields"),
|
content: const Text("Please fill in all required fields", style: TextStyle(color: Colors.black)),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: const Color(0xFF90EE90),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1179,8 +1186,10 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
if (ctx.mounted) {
|
if (ctx.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text("Error: ${e.toString()}"),
|
content: 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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,10 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Invitation sent to ${user.name}'),
|
content: Text('Invitation sent to ${user.name}', 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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,23 +186,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _isLoading ? null : () {
|
onPressed: _isLoading
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
? null
|
||||||
const SnackBar(
|
: () {
|
||||||
content: Row(
|
Navigator.of(context)
|
||||||
children: [
|
.pushReplacementNamed(AppRoutes.signup);
|
||||||
Icon(Icons.info, color: Colors.white),
|
},
|
||||||
SizedBox(width: 8),
|
child: const Text("Don't have an account? Sign Up"),
|
||||||
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"),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -765,17 +765,17 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
|
|
||||||
if (_businessId == null || _servicePointId == null) {
|
if (_businessId == null || _servicePointId == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: const Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.warning, color: Colors.white),
|
Icon(Icons.warning, color: Colors.black),
|
||||||
SizedBox(width: 8),
|
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,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
@ -853,12 +853,12 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.check_circle, color: Colors.white),
|
const Icon(Icons.check_circle, color: Colors.black),
|
||||||
const SizedBox(width: 8),
|
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,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
),
|
),
|
||||||
|
|
@ -870,12 +870,12 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error, color: Colors.white),
|
const Icon(Icons.error, color: Colors.black),
|
||||||
const SizedBox(width: 8),
|
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,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -78,9 +78,11 @@ class _ProfileSettingsScreenState extends State<ProfileSettingsScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Profile updated'),
|
content: const Text('Profile updated', style: TextStyle(color: Colors.black)),
|
||||||
backgroundColor: Colors.green,
|
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<ProfileSettingsScreen> {
|
||||||
setState(() => _isSaving = false);
|
setState(() => _isSaving = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to save: $e'),
|
content: Text('Failed to save: $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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
596
lib/screens/signup_screen.dart
Normal file
596
lib/screens/signup_screen.dart
Normal file
|
|
@ -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<SignupScreen> createState() => _SignupScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SignupScreenState extends State<SignupScreen> {
|
||||||
|
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<void> _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<void> _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<void> _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<AppState>();
|
||||||
|
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<void> _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<Color>(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<Color>(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<Color>(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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String, dynamic> 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<String, dynamic> 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 {
|
class Api {
|
||||||
static String? _userToken;
|
static String? _userToken;
|
||||||
|
|
||||||
|
|
@ -201,6 +244,91 @@ class Api {
|
||||||
await AuthStorage.clearAuth();
|
await AuthStorage.clearAuth();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Signup / OTP Verification
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
/// Send OTP to phone number for signup
|
||||||
|
/// Returns UUID to use in verifyOtp
|
||||||
|
static Future<SendOtpResponse> 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<VerifyOtpResponse> 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<void> 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<SendOtpResponse> resendOtp({required String phone}) async {
|
||||||
|
return sendOtp(phone: phone);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// Businesses (legacy model name: Restaurant)
|
// Businesses (legacy model name: Restaurant)
|
||||||
// -------------------------
|
// -------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: payfrit_app
|
name: payfrit_app
|
||||||
description: Payfrit MVP Flutter app scaffold
|
description: Payfrit MVP Flutter app scaffold
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 0.1.0+1
|
version: 3.0.0+9
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.4.0 <4.0.0"
|
sdk: ">=3.4.0 <4.0.0"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue