added wallet functionality

This commit is contained in:
Prince
2026-06-11 23:48:26 +02:00
parent ad79bf2af8
commit 2bcee59fd7
17 changed files with 1309 additions and 292 deletions

View File

@@ -1,4 +1,4 @@
BASE_URL=https://peakapi.qantra.co.zw/api
BASE_URL=https://api.velocityafrica.net/api
; BASE_URL=http://192.168.100.138:6950/api
; BASE_URL=http://173.212.247.232:6950/api
; BASE_URL=http://10.69.5.201:6950/api

View File

@@ -1,4 +1,4 @@
BASE_URL=https://peakapi.qantra.co.zw/api
BASE_URL=https://api.velocityafrica.net/api
CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com
SIMULATE_PAYMENT_SUCCESS=false
GATEWAY_SCRIPT_URL=https://na.gateway.mastercard.com/static/checkout/checkout.min.js

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:logger/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
var logger = Logger(printer: PrettyPrinter());
@@ -24,9 +25,22 @@ class Http {
);
}
Future<Map<String, String>> getHeaders() async {
Map<String, String> headers = {'Content-Type': 'application/json'};
var prefs = await SharedPreferences.getInstance();
String token = prefs.getString("token") ?? "";
headers['Authorization'] = 'Bearer $token';
return headers;
}
Future<dynamic> get(String url) async {
Map<String, String> headers = await getHeaders();
Response response;
response = await dio.get(baseUrl + url);
response = await dio.get(baseUrl + url, options: Options(headers: headers));
logger.i(response.data.toString());
return response.data;
}
@@ -45,7 +59,7 @@ class Http {
}
Future<dynamic> put(String url, dynamic data) async {
Map<String, String> headers = {'Content-Type': 'application/json'};
Map<String, String> headers = await getHeaders();
Response response;
response = await dio.put(
@@ -58,8 +72,13 @@ class Http {
}
Future<dynamic> delete(String url) async {
Map<String, String> headers = await getHeaders();
Response response;
response = await dio.delete(baseUrl + url);
response = await dio.delete(
baseUrl + url,
options: Options(headers: headers),
);
logger.i(response.data.toString());
return response.data;
}

View File

@@ -0,0 +1,101 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/accounts/models/account_model.dart';
import 'package:qpay/screens/accounts/models/statement_model.dart';
class AccountProvider extends ChangeNotifier {
final Http http = Http();
AccountProvider();
AccountModel? _account;
StatementModel? _statement;
bool _isLoadingAccount = false;
bool _isLoadingStatement = false;
String? _accountError;
String? _statementError;
AccountModel? get account => _account;
StatementModel? get statement => _statement;
bool get isLoadingAccount => _isLoadingAccount;
bool get isLoadingStatement => _isLoadingStatement;
String? get accountError => _accountError;
String? get statementError => _statementError;
Future<ApiResponse<AccountModel>> fetchAccount(String phoneNumber) async {
_isLoadingAccount = true;
_accountError = null;
notifyListeners();
try {
Map<String, dynamic> response = await http.get(
'/accounts/phone/$phoneNumber',
);
_account = AccountModel.fromJson(response);
_isLoadingAccount = false;
notifyListeners();
return ApiResponse.success(_account!);
} on DioException catch (e, s) {
logger.e(s);
_isLoadingAccount = false;
_accountError = 'Failed to fetch account. Please try again.';
notifyListeners();
return ApiResponse.failure(_accountError!);
} catch (e, s) {
logger.e(s);
_isLoadingAccount = false;
_accountError = 'An unexpected error occurred. Please try again.';
notifyListeners();
return ApiResponse.failure(_accountError!);
}
}
Future<ApiResponse<StatementModel>> fetchStatement({
required String phoneNumber,
required String startDate,
required String endDate,
}) async {
_isLoadingStatement = true;
_statementError = null;
notifyListeners();
try {
Map<String, dynamic> response = await http.get(
'/accounts/phone/$phoneNumber/statement'
'?startDate=$startDate&endDate=$endDate',
);
_statement = StatementModel.fromJson(response);
_isLoadingStatement = false;
notifyListeners();
return ApiResponse.success(_statement!);
} on DioException catch (e, s) {
logger.e(s);
_isLoadingStatement = false;
_statementError = 'Failed to fetch statement. Please try again.';
notifyListeners();
return ApiResponse.failure(_statementError!);
} catch (e, s) {
logger.e(s);
_isLoadingStatement = false;
_statementError = 'An unexpected error occurred. Please try again.';
notifyListeners();
return ApiResponse.failure(_statementError!);
}
}
void clearAccount() {
_account = null;
_accountError = null;
notifyListeners();
}
void clearStatement() {
_statement = null;
_statementError = null;
notifyListeners();
}
}

View File

@@ -0,0 +1,67 @@
import 'package:json_annotation/json_annotation.dart';
part 'account_model.g.dart';
@JsonSerializable()
class CurrencyModel {
final String id;
final String? createdAt;
final String name;
final String symbol;
final String code;
final double rate;
final bool defaultCurrency;
CurrencyModel({
required this.id,
this.createdAt,
required this.name,
required this.symbol,
required this.code,
required this.rate,
required this.defaultCurrency,
});
factory CurrencyModel.fromJson(Map<String, dynamic> json) =>
_$CurrencyModelFromJson(json);
Map<String, dynamic> toJson() => _$CurrencyModelToJson(this);
}
@JsonSerializable()
class AccountModel {
final String name;
final String description;
final String accountNumber;
final String alternateAccountNumber;
final String type;
final String category;
final String ledger;
final double balance;
final String partyType;
final String party;
final CurrencyModel currency;
final String customerStringId;
final String companyStringId;
AccountModel({
required this.name,
required this.description,
required this.accountNumber,
required this.alternateAccountNumber,
required this.type,
required this.category,
required this.ledger,
required this.balance,
required this.partyType,
required this.party,
required this.currency,
required this.customerStringId,
required this.companyStringId,
});
factory AccountModel.fromJson(Map<String, dynamic> json) =>
_$AccountModelFromJson(json);
Map<String, dynamic> toJson() => _$AccountModelToJson(this);
}

View File

@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'account_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CurrencyModel _$CurrencyModelFromJson(Map<String, dynamic> json) =>
CurrencyModel(
id: json['id'] as String,
createdAt: json['createdAt'] as String?,
name: json['name'] as String,
symbol: json['symbol'] as String,
code: json['code'] as String,
rate: (json['rate'] as num).toDouble(),
defaultCurrency: json['defaultCurrency'] as bool,
);
Map<String, dynamic> _$CurrencyModelToJson(CurrencyModel instance) =>
<String, dynamic>{
'id': instance.id,
'createdAt': instance.createdAt,
'name': instance.name,
'symbol': instance.symbol,
'code': instance.code,
'rate': instance.rate,
'defaultCurrency': instance.defaultCurrency,
};
AccountModel _$AccountModelFromJson(Map<String, dynamic> json) => AccountModel(
name: json['name'] as String,
description: json['description'] as String,
accountNumber: json['accountNumber'] as String,
alternateAccountNumber: json['alternateAccountNumber'] as String,
type: json['type'] as String,
category: json['category'] as String,
ledger: json['ledger'] as String,
balance: (json['balance'] as num).toDouble(),
partyType: json['partyType'] as String,
party: json['party'] as String,
currency: CurrencyModel.fromJson(json['currency'] as Map<String, dynamic>),
customerStringId: json['customerStringId'] as String,
companyStringId: json['companyStringId'] as String,
);
Map<String, dynamic> _$AccountModelToJson(AccountModel instance) =>
<String, dynamic>{
'name': instance.name,
'description': instance.description,
'accountNumber': instance.accountNumber,
'alternateAccountNumber': instance.alternateAccountNumber,
'type': instance.type,
'category': instance.category,
'ledger': instance.ledger,
'balance': instance.balance,
'partyType': instance.partyType,
'party': instance.party,
'currency': instance.currency,
'customerStringId': instance.customerStringId,
'companyStringId': instance.companyStringId,
};

View File

@@ -0,0 +1,63 @@
import 'package:json_annotation/json_annotation.dart';
part 'statement_model.g.dart';
@JsonSerializable()
class TransactionModel {
final String name;
final String narration;
final String party;
final String company;
final String postingDate;
final double amount;
final String account;
final String accountName;
final String currency;
final String type;
final double balance;
TransactionModel({
required this.name,
required this.narration,
required this.party,
required this.company,
required this.postingDate,
required this.amount,
required this.account,
required this.accountName,
required this.currency,
required this.type,
required this.balance,
});
factory TransactionModel.fromJson(Map<String, dynamic> json) =>
_$TransactionModelFromJson(json);
Map<String, dynamic> toJson() => _$TransactionModelToJson(this);
}
@JsonSerializable()
class StatementModel {
final int startBalance;
final double endBalance;
final List<TransactionModel> transactions;
final String accountName;
final String accountNumber;
final String startDate;
final String endDate;
StatementModel({
required this.startBalance,
required this.endBalance,
required this.transactions,
required this.accountName,
required this.accountNumber,
required this.startDate,
required this.endDate,
});
factory StatementModel.fromJson(Map<String, dynamic> json) =>
_$StatementModelFromJson(json);
Map<String, dynamic> toJson() => _$StatementModelToJson(this);
}

View File

@@ -0,0 +1,61 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'statement_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TransactionModel _$TransactionModelFromJson(Map<String, dynamic> json) =>
TransactionModel(
name: json['name'] as String,
narration: json['narration'] as String,
party: json['party'] as String,
company: json['company'] as String,
postingDate: json['postingDate'] as String,
amount: (json['amount'] as num).toDouble(),
account: json['account'] as String,
accountName: json['accountName'] as String,
currency: json['currency'] as String,
type: json['type'] as String,
balance: (json['balance'] as num).toDouble(),
);
Map<String, dynamic> _$TransactionModelToJson(TransactionModel instance) =>
<String, dynamic>{
'name': instance.name,
'narration': instance.narration,
'party': instance.party,
'company': instance.company,
'postingDate': instance.postingDate,
'amount': instance.amount,
'account': instance.account,
'accountName': instance.accountName,
'currency': instance.currency,
'type': instance.type,
'balance': instance.balance,
};
StatementModel _$StatementModelFromJson(Map<String, dynamic> json) =>
StatementModel(
startBalance: (json['startBalance'] as num).toInt(),
endBalance: (json['endBalance'] as num).toDouble(),
transactions: (json['transactions'] as List<dynamic>)
.map((e) => TransactionModel.fromJson(e as Map<String, dynamic>))
.toList(),
accountName: json['accountName'] as String,
accountNumber: json['accountNumber'] as String,
startDate: json['startDate'] as String,
endDate: json['endDate'] as String,
);
Map<String, dynamic> _$StatementModelToJson(StatementModel instance) =>
<String, dynamic>{
'startBalance': instance.startBalance,
'endBalance': instance.endBalance,
'transactions': instance.transactions,
'accountName': instance.accountName,
'accountNumber': instance.accountNumber,
'startDate': instance.startDate,
'endDate': instance.endDate,
};

View File

@@ -0,0 +1,390 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:qpay/screens/accounts/account_provider.dart';
import 'package:qpay/screens/accounts/models/account_model.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AccountBalanceWidget extends StatefulWidget {
const AccountBalanceWidget({super.key});
@override
State<AccountBalanceWidget> createState() => _AccountBalanceWidgetState();
}
class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
with SingleTickerProviderStateMixin {
late AccountProvider _accountProvider;
AccountModel? _usdAccount;
AccountModel? _zwgAccount;
bool _isLoadingUsd = false;
bool _isLoadingZwG = false;
String? _usdError;
String? _zwgError;
String? _phoneNumber;
late AnimationController _pulseController;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_accountProvider = AccountProvider();
_loadBalances();
}
@override
void dispose() {
_pulseController.dispose();
_accountProvider.dispose();
super.dispose();
}
Future<void> _loadBalances() async {
final prefs = await SharedPreferences.getInstance();
_phoneNumber = prefs.getString('phone');
if (_phoneNumber == null || _phoneNumber!.isEmpty) return;
setState(() {
_isLoadingUsd = true;
_isLoadingZwG = true;
});
// Fetch USD balance
final usdResult = await _accountProvider.fetchAccount(
'USD$_phoneNumber',
);
if (mounted) {
setState(() {
_isLoadingUsd = false;
if (usdResult.isSuccess && usdResult.data != null) {
_usdAccount = usdResult.data;
_usdError = null;
} else {
_usdError = usdResult.error;
}
});
}
// Fetch ZWG balance
final zwgResult = await _accountProvider.fetchAccount(
'ZWG$_phoneNumber',
);
if (mounted) {
setState(() {
_isLoadingZwG = false;
if (zwgResult.isSuccess && zwgResult.data != null) {
_zwgAccount = zwgResult.data;
_zwgError = null;
} else {
_zwgError = zwgResult.error;
}
});
}
if (mounted) {
_pulseController.forward();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
if (_phoneNumber == null || _phoneNumber!.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionLabel(
theme,
isDark,
Icons.account_balance_wallet_rounded,
'Account Balances',
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildBalanceCard(
theme: theme,
isDark: isDark,
currencyCode: 'USD',
currencyName: 'US Dollar',
flagAsset: 'united-states.png',
balance: _usdAccount?.balance,
isLoading: _isLoadingUsd,
error: _usdError,
gradientColors: [
const Color(0xFF0D47A1),
const Color(0xFF1976D2),
],
),
),
const SizedBox(width: 12),
Expanded(
child: _buildBalanceCard(
theme: theme,
isDark: isDark,
currencyCode: 'ZWG',
currencyName: 'Zimbabwe Gold',
flagAsset: 'zimbabwe.png',
balance: _zwgAccount?.balance,
isLoading: _isLoadingZwG,
error: _zwgError,
gradientColors: [
const Color(0xFF1B5E20),
const Color(0xFF388E3C),
],
),
),
],
),
const SizedBox(height: 8),
Center(
child: TextButton.icon(
onPressed: _isLoadingUsd || _isLoadingZwG
? null
: () {
_pulseController.reset();
_loadBalances();
},
icon: AnimatedBuilder(
animation: _pulseController,
builder: (context, child) {
return Transform.rotate(
angle: _pulseController.value * 6.2832,
child: Icon(
Icons.refresh_rounded,
size: 16,
color: _isLoadingUsd || _isLoadingZwG
? (isDark ? Colors.white24 : Colors.grey.shade400)
: theme.colorScheme.primary,
),
);
},
),
label: Text(
_isLoadingUsd || _isLoadingZwG
? 'Refreshing...'
: 'Refresh Balances',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _isLoadingUsd || _isLoadingZwG
? (isDark ? Colors.white24 : Colors.grey.shade400)
: theme.colorScheme.primary,
),
),
),
),
],
),
);
}
Widget _buildBalanceCard({
required ThemeData theme,
required bool isDark,
required String currencyCode,
required String currencyName,
required String flagAsset,
double? balance,
required bool isLoading,
String? error,
required List<Color> gradientColors,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: gradientColors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: gradientColors[0].withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top row: flag + currency code
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 1.5,
),
),
child: ClipOval(
child: Image.asset(
flagAsset,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
Icons.monetization_on_outlined,
size: 14,
color: Colors.white.withValues(alpha: 0.8),
),
),
),
),
const SizedBox(width: 8),
Text(
currencyCode,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Colors.white,
letterSpacing: 0.5,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
currencyName,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.9),
),
),
),
],
),
const SizedBox(height: 16),
// Balance amount
if (isLoading)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 80,
height: 14,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Container(
width: 50,
height: 10,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
),
],
)
else if (error != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.error_outline_rounded,
color: Colors.white.withValues(alpha: 0.7),
size: 20,
),
const SizedBox(height: 4),
Text(
'Unavailable',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
)
else ...[
Text(
_formatBalance(balance ?? 0.0),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 0.5,
height: 1.1,
),
),
const SizedBox(height: 4),
Text(
currencyCode == 'USD' ? 'Available Balance' : 'Available Balance',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w400,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
],
),
);
}
Widget _buildSectionLabel(
ThemeData theme,
bool isDark,
IconData icon,
String label,
) {
return Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 16, color: theme.colorScheme.primary),
),
const SizedBox(width: 10),
Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
letterSpacing: 0.3,
),
),
],
);
}
String _formatBalance(double balance) {
final formatter = NumberFormat('#,##0.00', 'en_US');
return '\$${formatter.format(balance)}';
}
}

View File

@@ -165,7 +165,10 @@ class ConfirmController extends ChangeNotifier {
"Network error. Please try again or contact support",
);
} catch (e, s) {
logger.e(s);
if (kDebugMode) {
logger.e(e);
logger.e(s);
}
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(

View File

@@ -134,94 +134,167 @@ class _ConfirmScreenState extends State<ConfirmScreen>
}
}
Future<String?> _showOtpBottomSheet() {
Future<String?> _showOtpBottomSheet() async {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final otpController = TextEditingController();
final formKey = GlobalKey<FormState>();
return showModalBottomSheet<String>(
context: context,
isDismissible: false,
enableDrag: false,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return Padding(
padding: EdgeInsets.only(
left: 24,
right: 24,
top: 24,
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 20),
Text(
'OTP Verification',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 8),
Text(
'Please enter the OTP sent to your mobile/email',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
const SizedBox(height: 20),
TextFormField(
controller: otpController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Enter OTP',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter the OTP';
}
return null;
},
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
if (formKey.currentState!.validate()) {
Navigator.of(context).pop(otpController.text.trim());
}
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
child: Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(24),
),
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
child: const Text(
'Submit OTP',
style: TextStyle(fontSize: 16),
),
Center(
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.verified_user_rounded,
size: 28,
color: theme.colorScheme.primary,
),
),
),
),
],
const SizedBox(height: 16),
Text(
'OTP Verification',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'An OTP has been sent to your registered mobile number/email. Please enter it below to proceed.',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
TextFormField(
controller: otpController,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 8,
color: isDark ? Colors.white : Colors.black87,
),
decoration: InputDecoration(
counterText: '',
hintText: '------',
hintStyle: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 8,
color: Colors.grey.shade400,
),
filled: true,
fillColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.red.shade400),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter the OTP';
}
if (value.trim().length < 4) {
return 'OTP must be at least 4 digits';
}
return null;
},
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
if (formKey.currentState?.validate() ?? false) {
Navigator.of(
sheetContext,
).pop(otpController.text.trim());
}
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
child: const Text(
'Submit',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
),
);

View File

@@ -103,10 +103,7 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
final batch = result.data!;
context.pushReplacement('/groups/${widget.group.id}/batches/${batch.id}');
} else {
AppSnackBar.showError(
context,
result.error ?? 'Failed to create batch.',
);
AppSnackBar.showError(context, result.error ?? 'Failed to create batch.');
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:qpay/screens/confirm/confirm_controller.dart';
import 'package:qpay/screens/groups/models/group_batch_model.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
@@ -27,6 +28,8 @@ class BatchDetailScreen extends StatefulWidget {
class _BatchDetailScreenState extends State<BatchDetailScreen>
with TickerProviderStateMixin {
late BatchController controller;
ConfirmController? _confirmController;
GroupBatchUpdateRequest? _cachedUpdateReq;
bool _showPollButton = false;
String _userId = '';
int _selectedTabIndex = 0;
@@ -140,6 +143,7 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
_headerAnimController.dispose();
_cardAnimController.dispose();
_contentAnimController.dispose();
_confirmController?.dispose();
controller.dispose();
super.dispose();
}
@@ -168,6 +172,9 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
return;
}
// Cache the update request so _onRequest can check the processor
_cachedUpdateReq = updateReq;
final result = await controller.confirmBatch(batch.id!, _userId);
if (!mounted) return;
if (!result.isSuccess) {
@@ -446,21 +453,54 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
Future<void> _onRequest() async {
final batch = _batch;
if (batch == null) return;
final result = await controller.requestBatch(batch.id!, _userId);
if (!mounted) return;
await _init(); // refresh batch and items after request
if (!result.isSuccess) {
_showError(result.error ?? 'Request failed.');
return;
}
// Check if the selected payment processor is WALLET
final isWallet =
_cachedUpdateReq?.paymentProcessorLabel == 'WALLET' ||
_selectedProcessor?.label == 'WALLET' ||
_batch?.paymentProcessorLabel == 'WALLET';
if (isWallet) {
// Show OTP bottom sheet for WALLET processor
final otp = await _showOtpBottomSheet();
if (!mounted) return;
if (otp == null) {
_showError('OTP verification cancelled.');
return;
}
// Initialize confirm controller if needed
_confirmController ??= ConfirmController(context);
final txn = result.data!;
final otpResult = await _confirmController!.updateVelocityTransactionOtp(
txn.id!,
{'verificationCode': otp},
);
if (!mounted) return;
if (!otpResult.isSuccess) {
_showError(otpResult.error ?? 'OTP verification failed.');
return;
}
}
// Normal flow after request (and optional OTP verification)
await _init();
final txn = result.data!;
final pollingStatus = txn.pollingStatus?.toUpperCase();
if (pollingStatus != null && _terminalStatuses.contains(pollingStatus)) {
await _init(); // refresh batch and items after request
await _init();
if (_successStatuses.contains(pollingStatus)) {
if (mounted) {
AppSnackBar.showSuccess(context, 'Payment completed successfully.');
@@ -473,13 +513,185 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
setState(() => _showPollButton = false);
} else {
setState(() => _showPollButton = true);
_showError('Auto-poll failed. You can retry manually.');
_showError(
pollResult.error ?? 'Auto-poll failed. You can retry manually.',
);
}
await _init(); // refresh batch and items after request
await _init();
}
}
/// Shows a bottom sheet for OTP input when the payment processor is WALLET.
/// Returns the entered OTP string, or `null` if dismissed.
Future<String?> _showOtpBottomSheet() async {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final otpController = TextEditingController();
final formKey = GlobalKey<FormState>();
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(24),
),
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
Center(
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.verified_user_rounded,
size: 28,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(height: 16),
Text(
'OTP Verification',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'An OTP has been sent to your registered mobile number/email. Please enter it below to proceed.',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
TextFormField(
controller: otpController,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 8,
color: isDark ? Colors.white : Colors.black87,
),
decoration: InputDecoration(
counterText: '',
hintText: '------',
hintStyle: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 8,
color: Colors.grey.shade400,
),
filled: true,
fillColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.red.shade400),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter the OTP';
}
if (value.trim().length < 4) {
return 'OTP must be at least 4 digits';
}
return null;
},
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
if (formKey.currentState?.validate() ?? false) {
Navigator.of(
sheetContext,
).pop(otpController.text.trim());
}
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
child: const Text(
'Submit',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
),
);
},
);
}
Future<void> _onPoll() async {
final batch = _batch;
if (batch == null) return;
@@ -678,65 +890,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
);
}
// ──────────────────────────────────────────────────────────────
// Status Badge
// ──────────────────────────────────────────────────────────────
Widget _buildStatusBadge(String status) {
final upper = status.toUpperCase();
final bool isSuccess = _successStatuses.contains(upper);
final bool isProcessing = upper == 'PROCESSING';
final bool isPending = upper == 'CREATED' || upper == 'CONFIRMED_ALL';
final Color bgColor = isSuccess
? const Color(0xFFD1FAE5)
: isProcessing
? const Color(0xFFDBEAFE)
: isPending
? const Color(0xFFFEF3C7)
: const Color(0xFFFEE2E2);
final Color textColor = isSuccess
? const Color(0xFF065F46)
: isProcessing
? const Color(0xFF1E40AF)
: isPending
? const Color(0xFF92400E)
: const Color(0xFF991B1B);
final Color dotColor = isSuccess
? const Color(0xFF10B981)
: isProcessing
? const Color(0xFF3B82F6)
: isPending
? const Color(0xFFF59E0B)
: const Color(0xFFEF4444);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
status,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: textColor,
),
),
],
),
);
}
// ──────────────────────────────────────────────────────────────
// Refresh Button (sits next to status pill; reloads the batch)
// ──────────────────────────────────────────────────────────────
@@ -836,7 +989,7 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildStatusBadge(status),
StatusChip(status: status),
const SizedBox(width: 8),
_buildRefreshButton(theme, isDark),
],
@@ -878,7 +1031,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
),
const SizedBox(height: 16),
_buildDottedDivider(isDark),
..._buildHeaderActionButtons(batch, theme),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [..._buildHeaderActionButtons(batch, theme)],
),
],
),
);

View File

@@ -403,7 +403,7 @@ class BatchController extends ChangeNotifier {
}
}
Future<ApiResponse<GroupBatch>> pollBatch(
Future<ApiResponse<TransactionModel>> pollBatch(
String batchId,
String userId,
) async {
@@ -418,11 +418,18 @@ class BatchController extends ChangeNotifier {
body.toJson(),
);
final response = _unwrap(raw);
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
model.currentBatch = batch;
final batchTransaction = TransactionModel.fromJson(
response as Map<String, dynamic>,
);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(batch);
if (response['status'] != 'FAILED') {
return ApiResponse.success(batchTransaction);
} else {
return ApiResponse.failure(
response['errorMessage'] ?? "Transaction failed",
);
}
} catch (e, s) {
logger.e(e);
logger.e(s);

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:qpay/screens/groups/models/group_batch_model.dart';
import 'package:intl/intl.dart';
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
@@ -108,10 +109,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
return Column(
children: [
_buildMembersSheetHandle(),
_buildMembersSheetHeader(
setSheetState,
scrollController,
),
_buildMembersSheetHeader(setSheetState, scrollController),
Expanded(
child: ListenableBuilder(
listenable: groupController,
@@ -119,9 +117,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
final allMembers = groupController.model.members;
final filtered = _filteredMembers(allMembers);
if (groupController.model.isLoading) {
return _buildMembersSkeletonList(
scrollController,
);
return _buildMembersSkeletonList(scrollController);
}
if (allMembers.isEmpty) {
return _buildMembersEmptyState();
@@ -185,10 +181,9 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
width: 40,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.1),
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
@@ -237,10 +232,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
},
decoration: InputDecoration(
hintText: 'Search members...',
hintStyle: TextStyle(
color: Colors.grey.shade500,
fontSize: 14,
),
hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14),
prefixIcon: Icon(
Icons.search,
color: Colors.grey.shade600,
@@ -301,19 +293,17 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
Widget _buildAnimatedMemberCard(int index, RecipientGroupMember member) {
final clampedIndex = index.clamp(0, 5);
final animation = Tween<Offset>(
begin: const Offset(0, 0.06),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _membersAnimController,
curve: Interval(
0.05 * clampedIndex,
1.0,
curve: Curves.easeOutCubic,
),
),
);
final animation =
Tween<Offset>(begin: const Offset(0, 0.06), end: Offset.zero).animate(
CurvedAnimation(
parent: _membersAnimController,
curve: Interval(
0.05 * clampedIndex,
1.0,
curve: Curves.easeOutCubic,
),
),
);
return FadeTransition(
opacity: _membersAnimController,
child: SlideTransition(
@@ -350,10 +340,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
],
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 14,
horizontal: 14,
),
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -411,10 +398,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.primary,
Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.7),
Theme.of(context).colorScheme.primary.withValues(alpha: 0.7),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
@@ -422,10 +406,9 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.25),
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.25),
blurRadius: 6,
offset: const Offset(0, 2),
),
@@ -444,15 +427,13 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
);
}
Widget _buildMemberSubtitleRow(
Recipient recipient,
String? memberAccount,
) {
Widget _buildMemberSubtitleRow(Recipient recipient, String? memberAccount) {
final account = memberAccount?.isNotEmpty == true
? memberAccount
: recipient.account;
final hasAccount = account != null && account.isNotEmpty;
final hasProvider = recipient.latestProviderLabel != null &&
final hasProvider =
recipient.latestProviderLabel != null &&
recipient.latestProviderLabel!.isNotEmpty;
if (!hasAccount && !hasProvider) {
return const SizedBox.shrink();
@@ -464,15 +445,11 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
children: [
if (hasProvider)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.08),
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text(
@@ -507,10 +484,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
Flexible(
child: Text(
text,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@@ -521,17 +495,11 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
Widget _buildMemberPopupMenu(RecipientGroupMember member) {
return PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
color: Colors.grey.shade500,
size: 20,
),
icon: Icon(Icons.more_vert, color: Colors.grey.shade500, size: 20),
padding: EdgeInsets.zero,
splashRadius: 20,
tooltip: 'More options',
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (value) async {
if (value == 'remove') {
await _confirmRemoveMember(member);
@@ -542,8 +510,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
value: 'remove',
child: Row(
children: [
Icon(Icons.person_remove_outlined,
size: 18, color: Colors.red),
Icon(Icons.person_remove_outlined, size: 18, color: Colors.red),
SizedBox(width: 10),
Text(
'Remove from group',
@@ -564,9 +531,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Text('Remove member'),
content: Text('Are you sure you want to remove $name from this group?'),
actions: [
@@ -628,10 +593,9 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
width: 88,
height: 88,
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.08),
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Icon(
@@ -690,10 +654,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
Text(
'No results for "$_memberQuery"',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
],
),
@@ -1163,6 +1124,8 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
final inSelectMode = batchController.model.selectMode;
final status = batch.status ?? 'UNKNOWN';
final createdAt = batch.createdAt;
final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
@@ -1227,25 +1190,44 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
color: Colors.grey.shade600,
),
),
if (batch.count != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
_countPill('${batch.count}', Colors.blue),
const SizedBox(width: 3),
_countPill(
'${batch.successfulCount ?? 0} ok',
Colors.green,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (parsedDate != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
DateFormat.yMMMd().add_jm().format(parsedDate),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
),
const SizedBox(width: 3),
_countPill(
'${batch.failedCount ?? 0} failed',
Colors.red,
),
if (batch.count != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
_statIcon(
Icons.people_outline,
'${batch.count}',
),
const SizedBox(width: 4),
_statIcon(
Icons.check_circle_outline,
'${batch.successfulCount ?? 0}',
),
const SizedBox(width: 4),
_statIcon(
Icons.cancel_outlined,
'${batch.failedCount ?? 0}',
),
],
),
],
),
),
),
],
),
],
),
),
@@ -1258,22 +1240,21 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
);
}
Widget _countPill(String label, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withAlpha(60)),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.w500,
Widget _statIcon(IconData icon, String label) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: Colors.grey.shade500),
const SizedBox(width: 3),
Text(
label,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
),
],
);
}

View File

@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/auth_state.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/accounts/widgets/account_balance_widget.dart';
import 'package:qpay/screens/home/home_controller.dart';
import 'package:qpay/screens/receipt/receipt_controller.dart';
import 'package:qpay/screens/transaction_controller.dart';
@@ -364,6 +365,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!_isLoggedIn) _buildLoginPrompt(theme, isDark),
if (_isLoggedIn) const AccountBalanceWidget(),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,

View File

@@ -5,39 +5,74 @@ class StatusChip extends StatelessWidget {
const StatusChip({super.key, required this.status});
Color _color() {
switch (status.toUpperCase()) {
case 'SUCCESS':
case 'COMPLETE':
return Colors.green;
case 'FAILED':
return Colors.red;
case 'PROCESSING':
return Colors.orange;
case 'CONFIRMED':
return Colors.blue;
default:
return Colors.grey;
}
static const _successStatuses = {'SUCCESS', 'COMPLETE', 'COMPLETED'};
static const _processingStatuses = {'PROCESSING', 'REQUESTED', 'CONFIRMING'};
static const _pendingStatuses = {'CREATED', 'CONFIRMED_ALL'};
Map<String, dynamic> _getStatusColors() {
final upper = status.toUpperCase();
final bool isSuccess = _successStatuses.contains(upper);
final bool isProcessing = _processingStatuses.contains(upper);
final bool isPending = _pendingStatuses.contains(upper);
final Color bgColor = isSuccess
? const Color(0xFFD1FAE5)
: isProcessing
? const Color(0xFFDBEAFE)
: isPending
? const Color(0xFFFEF3C7)
: const Color(0xFFFEE2E2);
final Color textColor = isSuccess
? const Color(0xFF065F46)
: isProcessing
? const Color(0xFF1E40AF)
: isPending
? const Color(0xFF92400E)
: const Color(0xFF991B1B);
final Color dotColor = isSuccess
? const Color(0xFF10B981)
: isProcessing
? const Color(0xFF3B82F6)
: isPending
? const Color(0xFFF59E0B)
: const Color(0xFFEF4444);
return {'bgColor': bgColor, 'textColor': textColor, 'dotColor': dotColor};
}
@override
Widget build(BuildContext context) {
final color = _color();
final colors = _getStatusColors();
final bgColor = colors['bgColor'] as Color;
final textColor = colors['textColor'] as Color;
final dotColor = colors['dotColor'] as Color;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withAlpha(25),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withAlpha(80)),
color: bgColor,
borderRadius: BorderRadius.circular(20),
),
child: Text(
status,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
status,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: textColor,
),
),
],
),
);
}