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:
John Mizerek 2026-01-09 23:16:10 -08:00
parent 77e3145175
commit 2522970078
14 changed files with 839 additions and 83 deletions

View file

@ -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 {

View file

@ -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"

View file

@ -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(),
};
}

View file

@ -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),
),
);
}
}

View file

@ -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),
),
);
}

View file

@ -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),
),
);
}

View file

@ -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),
),
);
}

View file

@ -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),
),
);
}

View file

@ -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"),
),
],
),

View file

@ -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),
),

View file

@ -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),
),
);
}

View 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),
),
),
],
),
);
}
}

View file

@ -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)
// -------------------------

View file

@ -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"