prototype complete
This commit is contained in:
@@ -1,17 +1,41 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:qpay/http/http.dart';
|
||||
import 'package:qpay/screens/home/home_controller.dart' as hc;
|
||||
import 'package:qpay/screens/transaction_controller.dart';
|
||||
import 'package:qpay/screens/transaction_controller.dart' as tc;
|
||||
|
||||
part 'pay_controller.freezed.dart';
|
||||
part 'pay_controller.g.dart';
|
||||
|
||||
class PayScreenModel {
|
||||
List<BillProduct> products = [];
|
||||
late BillProduct selectedProduct;
|
||||
dynamic formData = {};
|
||||
List<PaymentProcessor> paymentProcessors = [];
|
||||
hc.BillProvider? selectedProvider;
|
||||
BillProduct? selectedProduct;
|
||||
PaymentProcessor? selectedPaymentProcessor;
|
||||
bool isLoading = false;
|
||||
String? errorMessage;
|
||||
String? status;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class PaymentProcessor with _$PaymentProcessor {
|
||||
const factory PaymentProcessor({
|
||||
required String name,
|
||||
required String description,
|
||||
required String image,
|
||||
required String accountFieldName,
|
||||
required String accountFieldLabel,
|
||||
required String label,
|
||||
required String authType,
|
||||
}) = _PaymentProcessor;
|
||||
|
||||
factory PaymentProcessor.fromJson(Map<String, dynamic> json) =>
|
||||
_$PaymentProcessorFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -32,13 +56,94 @@ abstract class BillProduct with _$BillProduct {
|
||||
class PayController extends ChangeNotifier {
|
||||
final PayScreenModel model = PayScreenModel();
|
||||
final Http http = Http();
|
||||
late SharedPreferences prefs;
|
||||
late TransactionController transactionController;
|
||||
BuildContext context;
|
||||
|
||||
void updateSelectedProduct(BillProduct product) {
|
||||
model.selectedProduct = product;
|
||||
PayController(this.context) {
|
||||
transactionController = Provider.of<TransactionController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
model.selectedProvider = transactionController.model.selectedProvider;
|
||||
model.selectedProduct = transactionController.model.selectedProduct;
|
||||
}
|
||||
|
||||
void _showErrorSnackBar(String? message) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message.toString()),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// formData can come from a repeat transaction
|
||||
// otherwise build it from elements of the normal flow
|
||||
Future<void> doTransaction([tc.FormData? formData]) async {
|
||||
try {
|
||||
model.isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
|
||||
transactionController.model.formData =
|
||||
formData ??
|
||||
transactionController.model.formData.copyWith(
|
||||
type: "CONFIRM",
|
||||
billClientId: model.selectedProvider?.clientId ?? '',
|
||||
debitRef: 'peak',
|
||||
debitCurrency: 'USD',
|
||||
creditAccount: transactionController.model.formData.creditAccount,
|
||||
creditPhone: transactionController.model.formData.creditPhone,
|
||||
billName: model.selectedProvider?.name ?? '',
|
||||
paymentProcessorLabel: model.selectedPaymentProcessor?.label ?? '',
|
||||
paymentProcessorName: model.selectedPaymentProcessor?.name ?? '',
|
||||
paymentProcessorImage: model.selectedPaymentProcessor?.image ?? '',
|
||||
providerImage: model.selectedProvider?.image ?? '',
|
||||
providerLabel: model.selectedProvider?.label ?? '',
|
||||
userId: prefs.getString("userId") ?? '',
|
||||
creditName: transactionController.model.formData.creditName,
|
||||
creditEmail: transactionController.model.formData.creditEmail,
|
||||
productUid: model.selectedProduct?.uid,
|
||||
);
|
||||
|
||||
dynamic response = await http.post(
|
||||
'/transaction',
|
||||
transactionController.model.formData,
|
||||
);
|
||||
logger.i(response.toString());
|
||||
|
||||
if (response['status'] == 'SUCCESS') {
|
||||
model.status = response['status'];
|
||||
|
||||
transactionController.updateConfirmationData(response);
|
||||
} else {
|
||||
model.status = response['status'];
|
||||
model.errorMessage =
|
||||
response['errorMessage'] ??
|
||||
"Problem with the transaction. Please try again or contact support";
|
||||
_showErrorSnackBar(model.errorMessage);
|
||||
}
|
||||
} on DioException catch (e, s) {
|
||||
logger.e(s);
|
||||
_showErrorSnackBar("Network error. Please try again or contact support");
|
||||
} catch (e, s) {
|
||||
logger.e(s);
|
||||
_showErrorSnackBar(
|
||||
"Problem processing that transaction. Please try again or contact support",
|
||||
);
|
||||
}
|
||||
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> getProducts(String providerId) async {
|
||||
model.products = getFakeProducts();
|
||||
try {
|
||||
model.isLoading = true;
|
||||
|
||||
@@ -48,11 +153,86 @@ class PayController extends ChangeNotifier {
|
||||
model.products = response.map((e) => BillProduct.fromJson(e)).toList();
|
||||
} catch (e) {
|
||||
logger.e(e);
|
||||
model.errorMessage = e.toString();
|
||||
_showErrorSnackBar(
|
||||
"Problem fetching products, are you connected to the internet?",
|
||||
);
|
||||
}
|
||||
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> getPaymentProcessors() async {
|
||||
try {
|
||||
model.isLoading = true;
|
||||
model.paymentProcessors = getFakePaymentProcessors();
|
||||
notifyListeners();
|
||||
|
||||
List<dynamic> response = await http.get('/payment-processors');
|
||||
model.paymentProcessors =
|
||||
response.map((e) => PaymentProcessor.fromJson(e)).toList();
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
logger.e(e);
|
||||
_showErrorSnackBar(
|
||||
"Problem fetching payment processors, are you connected to the internet?",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void updateAdditionalRecipientDetails(String name, String email) {
|
||||
transactionController.model.formData = transactionController.model.formData
|
||||
.copyWith(creditName: name, creditEmail: email);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updatePhone(String phone) {
|
||||
transactionController.model.formData = transactionController.model.formData
|
||||
.copyWith(creditPhone: phone);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateAmount(String amount) {
|
||||
transactionController.model.formData = transactionController.model.formData
|
||||
.copyWith(amount: amount);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateSelectedProduct(BillProduct product) {
|
||||
model.selectedProduct = product;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateSelectedPaymentProcessor(PaymentProcessor processor) {
|
||||
model.selectedPaymentProcessor = processor;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<PaymentProcessor> getFakePaymentProcessors() {
|
||||
return [
|
||||
PaymentProcessor(
|
||||
name: "EcoCash",
|
||||
description: "Pay using your mobile wallet.",
|
||||
image: "ecocash.png",
|
||||
accountFieldName: "phoneNumber",
|
||||
accountFieldLabel: "EcoCash Phone Number",
|
||||
label: "ECOCASH",
|
||||
authType: "REMOTE",
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<BillProduct> getFakeProducts() {
|
||||
return [
|
||||
BillProduct(
|
||||
uid: '1',
|
||||
name: 'Product 1',
|
||||
description: 'Description 1',
|
||||
displayName: 'Product 1',
|
||||
defaultAmount: 100,
|
||||
requiresAmount: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,157 @@ part of 'pay_controller.dart';
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$PaymentProcessor {
|
||||
|
||||
String get name; String get description; String get image; String get accountFieldName; String get accountFieldLabel; String get label; String get authType;
|
||||
/// Create a copy of PaymentProcessor
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$PaymentProcessorCopyWith<PaymentProcessor> get copyWith => _$PaymentProcessorCopyWithImpl<PaymentProcessor>(this as PaymentProcessor, _$identity);
|
||||
|
||||
/// Serializes this PaymentProcessor to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is PaymentProcessor&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.image, image) || other.image == image)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)&&(identical(other.accountFieldLabel, accountFieldLabel) || other.accountFieldLabel == accountFieldLabel)&&(identical(other.label, label) || other.label == label)&&(identical(other.authType, authType) || other.authType == authType));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,name,description,image,accountFieldName,accountFieldLabel,label,authType);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PaymentProcessor(name: $name, description: $description, image: $image, accountFieldName: $accountFieldName, accountFieldLabel: $accountFieldLabel, label: $label, authType: $authType)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $PaymentProcessorCopyWith<$Res> {
|
||||
factory $PaymentProcessorCopyWith(PaymentProcessor value, $Res Function(PaymentProcessor) _then) = _$PaymentProcessorCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$PaymentProcessorCopyWithImpl<$Res>
|
||||
implements $PaymentProcessorCopyWith<$Res> {
|
||||
_$PaymentProcessorCopyWithImpl(this._self, this._then);
|
||||
|
||||
final PaymentProcessor _self;
|
||||
final $Res Function(PaymentProcessor) _then;
|
||||
|
||||
/// Create a copy of PaymentProcessor
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? description = null,Object? image = null,Object? accountFieldName = null,Object? accountFieldLabel = null,Object? label = null,Object? authType = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
|
||||
as String,accountFieldName: null == accountFieldName ? _self.accountFieldName : accountFieldName // ignore: cast_nullable_to_non_nullable
|
||||
as String,accountFieldLabel: null == accountFieldLabel ? _self.accountFieldLabel : accountFieldLabel // ignore: cast_nullable_to_non_nullable
|
||||
as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
|
||||
as String,authType: null == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _PaymentProcessor implements PaymentProcessor {
|
||||
const _PaymentProcessor({required this.name, required this.description, required this.image, required this.accountFieldName, required this.accountFieldLabel, required this.label, required this.authType});
|
||||
factory _PaymentProcessor.fromJson(Map<String, dynamic> json) => _$PaymentProcessorFromJson(json);
|
||||
|
||||
@override final String name;
|
||||
@override final String description;
|
||||
@override final String image;
|
||||
@override final String accountFieldName;
|
||||
@override final String accountFieldLabel;
|
||||
@override final String label;
|
||||
@override final String authType;
|
||||
|
||||
/// Create a copy of PaymentProcessor
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$PaymentProcessorCopyWith<_PaymentProcessor> get copyWith => __$PaymentProcessorCopyWithImpl<_PaymentProcessor>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$PaymentProcessorToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PaymentProcessor&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.image, image) || other.image == image)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)&&(identical(other.accountFieldLabel, accountFieldLabel) || other.accountFieldLabel == accountFieldLabel)&&(identical(other.label, label) || other.label == label)&&(identical(other.authType, authType) || other.authType == authType));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,name,description,image,accountFieldName,accountFieldLabel,label,authType);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PaymentProcessor(name: $name, description: $description, image: $image, accountFieldName: $accountFieldName, accountFieldLabel: $accountFieldLabel, label: $label, authType: $authType)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$PaymentProcessorCopyWith<$Res> implements $PaymentProcessorCopyWith<$Res> {
|
||||
factory _$PaymentProcessorCopyWith(_PaymentProcessor value, $Res Function(_PaymentProcessor) _then) = __$PaymentProcessorCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String name, String description, String image, String accountFieldName, String accountFieldLabel, String label, String authType
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$PaymentProcessorCopyWithImpl<$Res>
|
||||
implements _$PaymentProcessorCopyWith<$Res> {
|
||||
__$PaymentProcessorCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _PaymentProcessor _self;
|
||||
final $Res Function(_PaymentProcessor) _then;
|
||||
|
||||
/// Create a copy of PaymentProcessor
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? description = null,Object? image = null,Object? accountFieldName = null,Object? accountFieldLabel = null,Object? label = null,Object? authType = null,}) {
|
||||
return _then(_PaymentProcessor(
|
||||
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String,image: null == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
|
||||
as String,accountFieldName: null == accountFieldName ? _self.accountFieldName : accountFieldName // ignore: cast_nullable_to_non_nullable
|
||||
as String,accountFieldLabel: null == accountFieldLabel ? _self.accountFieldLabel : accountFieldLabel // ignore: cast_nullable_to_non_nullable
|
||||
as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
|
||||
as String,authType: null == authType ? _self.authType : authType // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$BillProduct {
|
||||
|
||||
|
||||
@@ -6,6 +6,28 @@ part of 'pay_controller.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_PaymentProcessor _$PaymentProcessorFromJson(Map<String, dynamic> json) =>
|
||||
_PaymentProcessor(
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
image: json['image'] as String,
|
||||
accountFieldName: json['accountFieldName'] as String,
|
||||
accountFieldLabel: json['accountFieldLabel'] as String,
|
||||
label: json['label'] as String,
|
||||
authType: json['authType'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PaymentProcessorToJson(_PaymentProcessor instance) =>
|
||||
<String, dynamic>{
|
||||
'name': instance.name,
|
||||
'description': instance.description,
|
||||
'image': instance.image,
|
||||
'accountFieldName': instance.accountFieldName,
|
||||
'accountFieldLabel': instance.accountFieldLabel,
|
||||
'label': instance.label,
|
||||
'authType': instance.authType,
|
||||
};
|
||||
|
||||
_BillProduct _$BillProductFromJson(Map<String, dynamic> json) => _BillProduct(
|
||||
uid: json['uid'] as String,
|
||||
name: json['name'] as String,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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/screens/pay/pay_controller.dart';
|
||||
import 'package:qpay/screens/transaction_controller.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class PayScreen extends StatefulWidget {
|
||||
@@ -14,16 +17,64 @@ 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 _descriptionController = TextEditingController();
|
||||
String _selectedBillType = '';
|
||||
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 ?? '';
|
||||
|
||||
// Initialize phone number and country code
|
||||
updatePhoneNumber(transactionController.model.formData.creditPhone ?? '');
|
||||
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
@@ -46,45 +97,78 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
),
|
||||
);
|
||||
|
||||
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();
|
||||
_descriptionController.dispose();
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_phoneController.dispose();
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showErrorSnackBar(String? message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message.toString()),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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: Consumer<PayController>(
|
||||
builder: (context, controller, child) {
|
||||
if (controller.model.errorMessage != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showErrorSnackBar(controller.model.errorMessage);
|
||||
});
|
||||
}
|
||||
body: ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, child) {
|
||||
return SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: SlideTransition(
|
||||
@@ -94,14 +178,101 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(height: 20),
|
||||
_buildBillProductSelector(controller),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
"TRANSACTIONS DETAILS",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Divider(color: Theme.of(context).colorScheme.primary),
|
||||
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: 24),
|
||||
_buildDescriptionField(),
|
||||
const SizedBox(height: 32),
|
||||
_buildPayButton(),
|
||||
const SizedBox(height: 20),
|
||||
// _buildPayButton(controller),
|
||||
...controller.model.paymentProcessors.map(
|
||||
(e) => _buildPaymentProcessorItem(e, context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -114,51 +285,130 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBillProductSelector(PayController controller) {
|
||||
Widget _buildPhoneField() {
|
||||
if (controller.model.selectedProvider?.requiresPhone == false) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Select Provider Product',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Notification Phone Number',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
items: [
|
||||
...controller.model.products.map(
|
||||
(e) => DropdownMenuItem<String>(
|
||||
value: e.uid,
|
||||
child: Text(e.displayName),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
_selectedBillType = newValue;
|
||||
});
|
||||
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: [
|
||||
@@ -166,13 +416,24 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
'Amount',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 5),
|
||||
TextFormField(
|
||||
controller: _amountController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.attach_money),
|
||||
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) {
|
||||
@@ -188,50 +449,238 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescriptionField() {
|
||||
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(
|
||||
'Description',
|
||||
'Provider Product',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Enter description (optional)',
|
||||
alignLabelWithHint: true,
|
||||
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 _buildPayButton() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// TODO: Implement payment logic
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Payment processed successfully!'),
|
||||
backgroundColor: Colors.green,
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Pay Now',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user