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"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
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 -->
|
||||
<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_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_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_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
|
||||
android:label="Payfrit"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import "../screens/about_screen.dart";
|
|||
import "../screens/address_edit_screen.dart";
|
||||
import "../screens/address_list_screen.dart";
|
||||
import "../screens/beacon_scan_screen.dart";
|
||||
import "../screens/signup_screen.dart";
|
||||
import "../screens/cart_view_screen.dart";
|
||||
import "../screens/group_order_invite_screen.dart";
|
||||
import "../screens/login_screen.dart";
|
||||
|
|
@ -32,6 +33,7 @@ class AppRoutes {
|
|||
static const String addressList = "/addresses";
|
||||
static const String addressEdit = "/address-edit";
|
||||
static const String about = "/about";
|
||||
static const String signup = "/signup";
|
||||
|
||||
static Map<String, WidgetBuilder> get routes => {
|
||||
splash: (_) => const SplashScreen(),
|
||||
|
|
@ -50,5 +52,6 @@ class AppRoutes {
|
|||
addressList: (_) => const AddressListScreen(),
|
||||
addressEdit: (_) => const AddressEditScreen(),
|
||||
about: (_) => const AboutScreen(),
|
||||
signup: (_) => const SignupScreen(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,9 +146,11 @@ class _AccountScreenState extends State<AccountScreen> {
|
|||
});
|
||||
|
||||
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<AccountScreen> {
|
|||
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<AccountScreen> {
|
|||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,8 +99,10 @@ class _AddressEditScreenState extends State<AddressEditScreen> {
|
|||
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<AddressEditScreen> {
|
|||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,9 +70,11 @@ class _AddressListScreenState extends State<AddressListScreen> {
|
|||
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<AddressListScreen> {
|
|||
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<AddressListScreen> {
|
|||
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<AddressListScreen> {
|
|||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -308,9 +308,11 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
// 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<CartViewScreen> {
|
|||
// 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<CartViewScreen> {
|
|||
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<CartViewScreen> {
|
|||
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<CartViewScreen> {
|
|||
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<CartViewScreen> {
|
|||
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<CartViewScreen> {
|
|||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,8 +70,10 @@ class _GroupOrderInviteScreenState extends State<GroupOrderInviteScreen> {
|
|||
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,23 +186,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
),
|
||||
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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -765,17 +765,17 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
|
||||
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<MenuBrowseScreen> {
|
|||
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<MenuBrowseScreen> {
|
|||
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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -78,9 +78,11 @@ class _ProfileSettingsScreenState extends State<ProfileSettingsScreen> {
|
|||
});
|
||||
|
||||
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<ProfileSettingsScreen> {
|
|||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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 {
|
||||
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<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)
|
||||
// -------------------------
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue