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

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