Files
velocity-pay-flutter/lib/screens/pay/pay_screen.dart

713 lines
27 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/pay/pay_controller.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
class PayScreen extends StatefulWidget {
const PayScreen({super.key});
@override
State<PayScreen> createState() => _PayScreenState();
}
class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
late PayController controller;
late TransactionController transactionController;
final List<Animation<Offset>> _animations = [];
final _formKey = GlobalKey<FormState>();
final _amountController = TextEditingController();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _phoneController = TextEditingController();
bool showAdditionalRecipientDetails = false;
String selectedCountryCode = '+263'; // Default to ZW
// Common country codes with flags and names
final List<Map<String, String>> countryCodes = [
{'code': '+263', 'name': 'ZW', 'flag': '🇿🇼'},
{'code': '+27', 'name': 'ZA', 'flag': '🇿🇦'},
{'code': '+1', 'name': 'US', 'flag': '🇺🇸'},
{'code': '+44', 'name': 'UK', 'flag': '🇬🇧'},
{'code': '+61', 'name': 'AU', 'flag': '🇦🇺'},
{'code': '+91', 'name': 'IN', 'flag': '🇮🇳'},
{'code': '+86', 'name': 'CN', 'flag': '🇨🇳'},
{'code': '+81', 'name': 'JP', 'flag': '🇯🇵'},
{'code': '+49', 'name': 'DE', 'flag': '🇩🇪'},
{'code': '+33', 'name': 'FR', 'flag': '🇫🇷'},
{'code': '+39', 'name': 'IT', 'flag': '🇮🇹'},
{'code': '+34', 'name': 'ES', 'flag': '🇪🇸'},
{'code': '+7', 'name': 'RU', 'flag': '🇷🇺'},
{'code': '+55', 'name': 'BR', 'flag': '🇧🇷'},
{'code': '+52', 'name': 'MX', 'flag': '🇲🇽'},
{'code': '+971', 'name': 'AE', 'flag': '🇦🇪'},
{'code': '+966', 'name': 'SA', 'flag': '🇸🇦'},
{'code': '+234', 'name': 'NG', 'flag': '🇳🇬'},
{'code': '+254', 'name': 'KE', 'flag': '🇰🇪'},
{'code': '+233', 'name': 'GH', 'flag': '🇬🇭'},
{'code': '+256', 'name': 'UG', 'flag': '🇺🇬'},
];
@override
void initState() {
super.initState();
controller = PayController(context);
controller.getPaymentProcessors();
controller.getProducts(controller.model.selectedProvider?.uid ?? "");
transactionController = Provider.of<TransactionController>(
context,
listen: false,
);
_nameController.text =
transactionController.model.formData.creditName ?? '';
_emailController.text =
transactionController.model.formData.creditEmail ?? '';
_amountController.text =
transactionController.model.formData.amount;
// Initialize phone number and country code
// updatePhoneNumber(transactionController.model.formData.creditPhone ?? '');
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
),
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
),
);
List<int> buttonIndices = [0, 1];
for (int i = 0; i < buttonIndices.length; i++) {
_animations.add(
Tween<Offset>(begin: Offset(-0.15, 0), end: Offset.zero).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.055 * i, 1.0, curve: Curves.easeOut),
),
),
);
}
_controller.forward();
}
Future<void> updatePhoneNumber(String phoneNumber) async {
String existingPhone = phoneNumber;
if (existingPhone.isNotEmpty) {
// Try to extract country code from existing phone number
for (var country in countryCodes) {
if (existingPhone.startsWith(country['code']!)) {
setState(() {
selectedCountryCode = country['code']!;
});
_phoneController.text = existingPhone.substring(
country['code']!.length,
);
break;
}
}
// If no country code found, use default and set the full number
if (_phoneController.text.isEmpty) {
_phoneController.text = existingPhone;
}
}
}
@override
void dispose() {
_controller.dispose();
_amountController.dispose();
_nameController.dispose();
_emailController.dispose();
_phoneController.dispose();
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading:
context.canPop()
? IconButton(
onPressed: () {
context.pop();
},
icon: const Icon(Icons.arrow_back),
)
: SizedBox.shrink(),
title: const Text(
'Make Payment',
style: TextStyle(color: Colors.black87),
),
),
body: ListenableBuilder(
listenable: controller,
builder: (context, child) {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Form(
key: _formKey,
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: double.parse(constraints.maxWidth > ResponsivePolicy.md ?
ResponsivePolicy.md.toString() :
constraints.maxWidth.toString()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.black87.withAlpha(20)),
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(vertical: 10, horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"TRANSACTIONS DETAILS",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Provider",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller.model.selectedProvider?.name ?? "",
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller
.model
.selectedProvider
?.accountFieldName ??
"",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
transactionController
.model
.formData
.creditAccount,
style: TextStyle(fontSize: 16),
),
],
),
],
),
),
SizedBox(height: 20),
InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: () {
setState(() {
showAdditionalRecipientDetails =
!showAdditionalRecipientDetails;
});
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () {},
icon: Icon(
showAdditionalRecipientDetails
? Icons.arrow_drop_up
: Icons.arrow_drop_down,
),
color: Theme.of(context).colorScheme.primary,
),
Text(
showAdditionalRecipientDetails
? "Hide additional recipient details"
: "Show additional recipient details",
style: TextStyle(fontSize: 16),
),
],
),
),
if (showAdditionalRecipientDetails)
_buildAdditionalRecipientDetails(),
const SizedBox(height: 7),
if (controller.model.products.isNotEmpty)
_buildBillProductSelector(controller),
const SizedBox(height: 7),
_buildPhoneField(),
const SizedBox(height: 7),
_buildAmountField(),
const SizedBox(height: 20),
// _buildPayButton(controller),
...controller.model.paymentProcessors.map(
(e) => _buildPaymentProcessorItem(e, context),
),
],
),
);
}
),
),
),
),
),
),
);
},
),
);
}
Widget _buildPhoneField() {
if (controller.model.selectedProvider?.requiresPhone == false) {
return SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Debit Phone Number',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
TextButton(
onPressed: () async {
if (await FlutterContacts.requestPermission()) {
final contact = await FlutterContacts.openExternalPick();
if (contact != null) {
updatePhoneNumber(contact.phones.first.normalizedNumber);
}
}
},
child: Text(
"Fetch from contacts",
style: TextStyle(fontSize: 12, color: Colors.red),
),
),
],
),
const SizedBox(height: 5),
Row(
children: [
// Country code dropdown
Container(
width: 100,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
),
borderRadius: BorderRadius.circular(12),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedCountryCode,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
padding: const EdgeInsets.symmetric(horizontal: 8),
items:
countryCodes.map((country) {
return DropdownMenuItem<String>(
value: country['code'],
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(country['flag']!),
const SizedBox(width: 4),
Text(
country['code']!,
style: const TextStyle(fontSize: 14),
),
],
),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
setState(() {
selectedCountryCode = newValue;
});
}
},
),
),
),
const SizedBox(width: 8),
// Phone number field
Expanded(
child: TextFormField(
controller: _phoneController,
keyboardType: TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
hintText: 'Enter phone number',
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.secondary,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.2),
),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a phone number';
}
// Remove any non-digit characters for validation
String digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
if (digitsOnly.length < 7) {
return 'Phone number must be at least 7 digits';
}
if (digitsOnly.length > 15) {
return 'Phone number is too long';
}
return null;
},
),
),
],
),
],
);
}
Widget _buildAmountField() {
if (controller.model.selectedProvider?.requiresAmount == false) {
return SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Amount',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 5),
TextFormField(
controller: _amountController,
keyboardType: TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
hintText: 'Enter amount',
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.secondary,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an amount';
}
if (double.tryParse(value) == null) {
return 'Please enter a valid amount';
}
return null;
},
),
],
);
}
Widget _buildAdditionalRecipientDetails() {
return SlideTransition(
position: _slideAnimation,
child: Column(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Name',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 5),
TextFormField(
controller: _nameController,
keyboardType: TextInputType.text,
decoration: InputDecoration(
hintText: 'Enter recipient\'s name',
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.secondary,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.2),
),
),
),
),
],
),
const SizedBox(height: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Email',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 5),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
hintText: 'Enter recipient\'s email',
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.secondary,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.2),
),
),
),
),
],
),
const SizedBox(height: 10),
Divider(color: Theme.of(context).colorScheme.primary),
],
),
);
}
Widget _buildBillProductSelector(PayController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 20),
const Text(
'Provider Product',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 5),
Skeletonizer(
enabled: controller.model.isLoading,
child: DropdownButtonHideUnderline(
child: DropdownButtonFormField<String>(
value: controller.model.selectedProduct?.uid,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
decoration: InputDecoration(
hintText: 'Select Product',
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.2),
),
),
),
items: [
DropdownMenuItem(child: Text("Select Product")),
...controller.model.products.map(
(e) => DropdownMenuItem<String>(
value: e.uid,
child: Text(e.displayName),
),
),
],
onChanged: (String? newValue) {
if (newValue != null) {
BillProduct product = controller.model.products.firstWhere(
(e) => e.uid == newValue,
);
controller.updateSelectedProduct(product);
}
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please select a product';
}
return null;
},
),
),
),
],
);
}
Widget _buildPaymentProcessorItem(PaymentProcessor e, BuildContext context) {
return Column(
children: [
Skeletonizer(
enabled: controller.model.isLoading,
child: SlideTransition(
position:
_animations[controller.model.paymentProcessors.indexOf(e)],
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.black87.withAlpha(20)),
borderRadius: BorderRadius.circular(12),
gradient: LinearGradient(
colors: [
Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
Theme.of(
context,
).colorScheme.tertiary.withValues(alpha: 0.05),
],
stops: const [0.3, 0.7],
begin: Alignment.topRight,
end: Alignment.bottomLeft,
),
),
child: InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: () async {
if (_formKey.currentState!.validate()) {
if (controller.model.isLoading) return;
controller.updateAmount(_amountController.text);
if (!controller.model.selectedProvider!.requiresAmount) {
controller.updateAmount(
controller.model.selectedProduct!.defaultAmount
.toString(),
);
}
// Update phone number with country code
String fullPhoneNumber =
selectedCountryCode + _phoneController.text;
controller.updatePhone(fullPhoneNumber);
if (showAdditionalRecipientDetails) {
controller.updateAdditionalRecipientDetails(
_nameController.text,
_emailController.text,
);
}
controller.updateSelectedPaymentProcessor(e);
transactionController.updateSelectedPaymentProcessor(e);
await controller.doTransaction();
if (controller.model.status == "SUCCESS" &&
context.mounted) {
context.push('/confirm');
}
}
},
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Image(image: AssetImage("assets/${e.image}")),
),
title: Text(
e.name,
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
e.description,
style: TextStyle(
fontWeight: FontWeight.normal,
fontSize: 10,
),
),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(
context,
).colorScheme.secondary.withValues(alpha: 0.7),
),
),
),
),
),
),
SizedBox(height: 10),
],
);
}
}