feat: Implement major features and fix theming bug

This commit introduces several new features and a critical bug fix.

- Implemented a full "Quick Pay" flow for both within and outside the bank, including IFSC validation, beneficiary verification, and a TPIN-based payment process.
- Added a date range filter to the Account Statement screen and streamlined the UI by removing the amount filters.
- Fixed a major bug that prevented dynamic theme changes from being applied. The app now correctly switches between color themes.
- Refactored and improved beneficiary management, transaction models, and the fund transfer flow to support NEFT/RTGS.
This commit is contained in:
asif
2025-08-11 04:06:05 +05:30
parent 3024ddef15
commit f91d0f739b
34 changed files with 1638 additions and 911 deletions

View File

@@ -1,9 +1,19 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:kmobile/api/services/neft_service.dart';
import 'package:kmobile/api/services/rtgs_service.dart';
import 'package:kmobile/data/models/neft_transaction.dart';
import 'package:kmobile/data/models/payment_response.dart';
import 'package:kmobile/data/models/rtgs_transaction.dart';
import 'package:kmobile/features/fund_transfer/screens/payment_animation.dart';
import 'package:kmobile/features/fund_transfer/screens/transaction_pin_screen.dart';
import '../../../l10n/app_localizations.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:flutter_swipe_button/flutter_swipe_button.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import '../../../../di/injection.dart';
import '../../../../api/services/beneficiary_service.dart';
class QuickPayOutsideBankScreen extends StatefulWidget {
final String debitAccount;
@@ -26,9 +36,12 @@ class _QuickPayOutsideBankScreen extends State<QuickPayOutsideBankScreen> {
final ifscController = TextEditingController();
final phoneController = TextEditingController();
final amountController = TextEditingController();
final service = getIt<BeneficiaryService>();
//String accountType = 'Savings';
late String accountType;
bool _isValidating = false;
bool _isBeneficiaryValidated = false;
String? _validationError;
@override
void initState() {
@@ -40,12 +53,71 @@ class _QuickPayOutsideBankScreen extends State<QuickPayOutsideBankScreen> {
});
}
//final List<String> transactionModes = ['NEFT', 'RTGS', 'IMPS'];
void _validateIFSC() async {
final ifsc = ifscController.text.trim().toUpperCase();
if (ifsc.isEmpty) return;
final result = await service.validateIFSC(ifsc);
if (mounted) {
if (result.bankName == '') {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).invalidIfsc)),
);
bankNameController.clear();
branchNameController.clear();
} else {
bankNameController.text = result.bankName;
branchNameController.text = result.branchName;
}
}
}
void _validateBeneficiary() async {
FocusScope.of(context).unfocus();
setState(() {
_isValidating = true;
_validationError = null;
_isBeneficiaryValidated = false;
nameController.text = ''; // clear previous name
});
final String accountNo = accountNumberController.text.trim();
final String ifsc = ifscController.text.trim();
// TODO: Replace with actual remitter name
final String remitter = "Unknown";
final service = getIt<BeneficiaryService>();
try {
final String beneficiaryName = await service.validateBeneficiary(
accountNo: accountNo,
ifscCode: ifsc,
remitterName: remitter,
);
setState(() {
nameController.text = beneficiaryName;
_isBeneficiaryValidated = true;
_validationError = null;
});
} catch (e) {
setState(() {
_validationError = e.toString();
_isBeneficiaryValidated = false;
});
} finally {
if (mounted) {
setState(() {
_isValidating = false;
});
}
}
}
List<String> transactionModes(BuildContext context) {
return [
AppLocalizations.of(context).neft,
AppLocalizations.of(context).rtgs,
AppLocalizations.of(context).imps,
];
}
@@ -65,6 +137,131 @@ class _QuickPayOutsideBankScreen extends State<QuickPayOutsideBankScreen> {
super.dispose();
}
void _onProceedToPay() {
if (_formKey.currentState!.validate()) {
if (!_isBeneficiaryValidated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please validate beneficiary details first.')),
);
return;
}
final amount = double.tryParse(amountController.text) ?? 0;
final selectedMode = transactionModes(context)[selectedTransactionIndex];
final isRtgs = selectedMode == AppLocalizations.of(context).rtgs;
if (isRtgs && amount < 200000) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Invalid Amount for RTGS'),
content: const Text(
'RTGS transactions require a minimum amount of 200,000. Please enter a higher amount or select NEFT as the transaction mode.'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('OK'),
),
],
),
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TransactionPinScreen(
onPinCompleted: (pinScreenContext, tpin) async {
if (!isRtgs) {
// NEFT
final neftTx = NeftTransaction(
fromAccount: widget.debitAccount,
toAccount: accountNumberController.text,
amount: amountController.text,
ifscCode: ifscController.text,
remitterName: "Unknown", // TODO: Get actual remitter name
beneficiaryName: nameController.text,
tpin: tpin,
);
final neftService = getIt<NeftService>();
final completer = Completer<PaymentResponse>();
Navigator.of(pinScreenContext).pushReplacement(
MaterialPageRoute(
builder: (_) =>
PaymentAnimationScreen(paymentResponse: completer.future),
),
);
try {
final neftResponse =
await neftService.processNeftTransaction(neftTx);
final paymentResponse = PaymentResponse(
isSuccess: neftResponse.message.toUpperCase() == 'SUCCESS',
date: DateTime.now(),
creditedAccount: neftTx.toAccount,
amount: neftTx.amount,
currency: 'INR',
utr: neftResponse.utr,
);
completer.complete(paymentResponse);
} catch (e) {
final paymentResponse = PaymentResponse(
isSuccess: false,
errorMessage: e.toString(),
);
completer.complete(paymentResponse);
}
} else {
// RTGS
final rtgsTx = RtgsTransaction(
fromAccount: widget.debitAccount,
toAccount: accountNumberController.text,
amount: amountController.text,
ifscCode: ifscController.text,
remitterName: "Unknown", // TODO: Get actual remitter name
beneficiaryName: nameController.text,
tpin: tpin,
);
final rtgsService = getIt<RtgsService>();
final completer = Completer<PaymentResponse>();
Navigator.of(pinScreenContext).pushReplacement(
MaterialPageRoute(
builder: (_) =>
PaymentAnimationScreen(paymentResponse: completer.future),
),
);
try {
final rtgsResponse =
await rtgsService.processRtgsTransaction(rtgsTx);
final paymentResponse = PaymentResponse(
isSuccess: rtgsResponse.message.toUpperCase() == 'SUCCESS',
date: DateTime.now(),
creditedAccount: rtgsTx.toAccount,
amount: rtgsTx.amount,
currency: 'INR',
utr: rtgsResponse.utr,
);
completer.complete(paymentResponse);
} catch (e) {
final paymentResponse = PaymentResponse(
isSuccess: false,
errorMessage: e.toString(),
);
completer.complete(paymentResponse);
}
}
},
),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -103,19 +300,24 @@ class _QuickPayOutsideBankScreen extends State<QuickPayOutsideBankScreen> {
child: ListView(
children: [
const SizedBox(height: 10),
Row(
children: [
Text(AppLocalizations.of(context).debitFrom),
Text(
widget.debitAccount,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
],
Text(
AppLocalizations.of(context).debitFrom,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 20),
Card(
elevation: 0,
margin: const EdgeInsets.symmetric(vertical: 8.0),
child: ListTile(
leading: Image.asset(
'assets/images/logo.png',
width: 40,
height: 40,
),
title: Text(widget.debitAccount),
subtitle: Text(AppLocalizations.of(context).ownBank),
),
),
const SizedBox(height: 24),
TextFormField(
decoration: InputDecoration(
labelText: AppLocalizations.of(context).accountNumber,
@@ -134,11 +336,17 @@ class _QuickPayOutsideBankScreen extends State<QuickPayOutsideBankScreen> {
keyboardType: TextInputType.number,
obscureText: true,
textInputAction: TextInputAction.next,
onChanged: (value) {
nameController.clear();
setState(() {
_isBeneficiaryValidated = false;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context).accountNumberRequired;
} else if (value.length != 11) {
return AppLocalizations.of(context).validAccountNumber;
} else if (value.length < 7 || value.length > 20) {
return 'Account number must be between 7 and 20 digits';
}
return null;
},
@@ -173,79 +381,6 @@ class _QuickPayOutsideBankScreen extends State<QuickPayOutsideBankScreen> {
},
),
const SizedBox(height: 25),
TextFormField(
decoration: InputDecoration(
labelText: AppLocalizations.of(context).name,
border: OutlineInputBorder(),
isDense: true,
filled: true,
fillColor: Theme.of(context).scaffoldBackgroundColor,
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.black),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.black, width: 2),
),
),
controller: nameController,
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context).nameRequired;
}
return null;
},
),
const SizedBox(height: 25),
TextFormField(
decoration: InputDecoration(
labelText: AppLocalizations.of(context).bankName,
border: OutlineInputBorder(),
isDense: true,
filled: true,
fillColor: Theme.of(context).scaffoldBackgroundColor,
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.black),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.black, width: 2),
),
),
controller: bankNameController,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context).bankNameRequired;
}
return null;
},
),
const SizedBox(height: 25),
TextFormField(
decoration: InputDecoration(
labelText: AppLocalizations.of(context).branchName,
border: OutlineInputBorder(),
isDense: true,
filled: true,
fillColor: Theme.of(context).scaffoldBackgroundColor,
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.black),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.black, width: 2),
),
),
controller: branchNameController,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context).branchNameRequired;
}
return null;
},
),
const SizedBox(height: 25),
Row(
children: [
Expanded(
@@ -265,9 +400,27 @@ class _QuickPayOutsideBankScreen extends State<QuickPayOutsideBankScreen> {
),
controller: ifscController,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) {
_validateIFSC();
},
onChanged: (value) {
final trimmed = value.trim().toUpperCase();
if (trimmed.length < 11) {
// clear bank/branch if backspace or changed
bankNameController.clear();
branchNameController.clear();
}
},
validator: (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context).ifscRequired;
final pattern = RegExp(r'^[A-Z]{4}0[A-Z0-9]{6}$');
if (value == null || value.trim().isEmpty) {
return AppLocalizations.of(context).enterIfsc;
} else if (!pattern.hasMatch(
value.trim().toUpperCase(),
)) {
return AppLocalizations.of(
context,
).invalidIfscFormat;
}
return null;
},
@@ -307,6 +460,113 @@ class _QuickPayOutsideBankScreen extends State<QuickPayOutsideBankScreen> {
),
],
),
const SizedBox(height: 25),
TextFormField(
controller: bankNameController,
enabled: false,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).bankName,
border: const OutlineInputBorder(),
isDense: true,
filled: true,
fillColor: Theme.of(context).dialogBackgroundColor,
enabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.black),
),
focusedBorder: const OutlineInputBorder(
borderSide: BorderSide(
color: Colors.black,
width: 2,
),
),
),
),
const SizedBox(height: 25),
TextFormField(
controller: branchNameController,
enabled: false,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).branchName,
border: const OutlineInputBorder(),
isDense: true,
filled: true,
fillColor: Theme.of(context).dialogBackgroundColor,
enabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.black),
),
focusedBorder: const OutlineInputBorder(
borderSide: BorderSide(
color: Colors.black,
width: 2,
),
),
),
),
const SizedBox(height: 24),
if (!_isBeneficiaryValidated)
Padding(
padding: const EdgeInsets.only(bottom: 24),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isValidating
? null
: () {
if (confirmAccountNumberController
.text ==
accountNumberController.text) {
_validateBeneficiary();
} else {
setState(() {
_validationError =
'Account numbers do not match.';
});
}
},
child: _isValidating
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Text('Validate Beneficiary'),
),
),
),
if (_validationError != null)
Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: Text(
_validationError!,
style: TextStyle(color: Colors.red),
),
),
TextFormField(
controller: nameController,
enabled: false,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).name,
border: const OutlineInputBorder(),
isDense: true,
filled: true,
fillColor: Theme.of(context).dialogBackgroundColor,
enabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.black),
),
focusedBorder: const OutlineInputBorder(
borderSide:
BorderSide(color: Colors.black, width: 2),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context).nameRequired;
}
return null;
},
),
const SizedBox(height: 25),
Row(
children: [
@@ -382,38 +642,16 @@ class _QuickPayOutsideBankScreen extends State<QuickPayOutsideBankScreen> {
Align(
alignment: Alignment.center,
child: SwipeButton.expand(
thumb: Icon(Icons.arrow_forward, color: Theme.of(context).scaffoldBackgroundColor),
activeThumbColor: Theme.of(context).primaryColorDark,
activeTrackColor: Theme.of(context).primaryColorLight,
thumb: Icon(Icons.arrow_forward, color: Theme.of(context).dialogBackgroundColor),
activeThumbColor: Theme.of(context).primaryColor,
activeTrackColor: Theme.of(context).colorScheme.secondary.withAlpha(100),
borderRadius: BorderRadius.circular(30),
height: 56,
onSwipe: _onProceedToPay,
child: Text(
AppLocalizations.of(context).swipeToPay,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
onSwipe: () {
if (_formKey.currentState!.validate()) {
// Perform payment logic
final selectedMode = transactionModes(
context,
)[selectedTransactionIndex];
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${AppLocalizations.of(context).payingVia} $selectedMode...',
),
),
);
// Navigator.push(
// context,
// MaterialPageRoute(
// builder: (context) =>
// const TransactionPinScreen(
// transactionData: {},
// transactionCode: 'PAYMENT',
// )));
}
},
),
),
],
@@ -457,4 +695,4 @@ class _QuickPayOutsideBankScreen extends State<QuickPayOutsideBankScreen> {
),
);
}
}
}

View File

@@ -64,7 +64,6 @@ class _QuickPayScreen extends State<QuickPayScreen> {
),
const Divider(height: 1),
QuickPayManagementTile(
disable: true,
icon: Symbols.output_circle,
label: AppLocalizations.of(context).outsideBank,
onTap: () {

View File

@@ -1,9 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:flutter_swipe_button/flutter_swipe_button.dart';
import 'package:kmobile/api/services/beneficiary_service.dart';
import 'package:kmobile/api/services/payment_service.dart';
import 'package:kmobile/data/models/payment_response.dart';
import 'package:kmobile/data/models/transfer.dart';
import 'package:kmobile/di/injection.dart';
import 'package:kmobile/features/fund_transfer/screens/payment_animation.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import '../../../l10n/app_localizations.dart';
import '../../fund_transfer/screens/transaction_pin_screen.dart';
@@ -363,12 +367,26 @@ class _QuickPayWithinBankScreen extends State<QuickPayWithinBankScreen> {
context,
MaterialPageRoute(
builder: (context) => TransactionPinScreen(
transactionData: Transfer(
fromAccount: widget.debitAccount,
toAccount: accountNumberController.text,
toAccountType: _selectedAccountType!,
amount: amountController.text,
),
onPinCompleted: (pinScreenContext, tpin) async {
final transfer = Transfer(
fromAccount: widget.debitAccount,
toAccount: accountNumberController.text,
toAccountType: _selectedAccountType!,
amount: amountController.text,
tpin: tpin,
);
final paymentService = getIt<PaymentService>();
final paymentResponseFuture = paymentService
.processQuickPayWithinBank(transfer);
Navigator.of(pinScreenContext).pushReplacement(
MaterialPageRoute(
builder: (_) => PaymentAnimationScreen(
paymentResponse: paymentResponseFuture),
),
);
},
),
),
);