diff --git a/assets/.env b/assets/.env index 26b5e8c..718f848 100644 --- a/assets/.env +++ b/assets/.env @@ -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 diff --git a/assets/.env.live b/assets/.env.live index 61bcc39..c7bc85b 100644 --- a/assets/.env.live +++ b/assets/.env.live @@ -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 diff --git a/lib/http/http.dart b/lib/http/http.dart index 7aca082..e467ca0 100644 --- a/lib/http/http.dart +++ b/lib/http/http.dart @@ -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> getHeaders() async { + Map headers = {'Content-Type': 'application/json'}; + + var prefs = await SharedPreferences.getInstance(); + String token = prefs.getString("token") ?? ""; + + headers['Authorization'] = 'Bearer $token'; + + return headers; + } + Future get(String url) async { + Map 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 put(String url, dynamic data) async { - Map headers = {'Content-Type': 'application/json'}; + Map headers = await getHeaders(); Response response; response = await dio.put( @@ -58,8 +72,13 @@ class Http { } Future delete(String url) async { + Map 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; } diff --git a/lib/screens/accounts/account_provider.dart b/lib/screens/accounts/account_provider.dart index e69de29..a3bc483 100644 --- a/lib/screens/accounts/account_provider.dart +++ b/lib/screens/accounts/account_provider.dart @@ -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> fetchAccount(String phoneNumber) async { + _isLoadingAccount = true; + _accountError = null; + notifyListeners(); + + try { + Map 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> fetchStatement({ + required String phoneNumber, + required String startDate, + required String endDate, + }) async { + _isLoadingStatement = true; + _statementError = null; + notifyListeners(); + + try { + Map 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(); + } +} diff --git a/lib/screens/accounts/models/account_model.dart b/lib/screens/accounts/models/account_model.dart new file mode 100644 index 0000000..68a3af0 --- /dev/null +++ b/lib/screens/accounts/models/account_model.dart @@ -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 json) => + _$CurrencyModelFromJson(json); + + Map 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 json) => + _$AccountModelFromJson(json); + + Map toJson() => _$AccountModelToJson(this); +} \ No newline at end of file diff --git a/lib/screens/accounts/models/account_model.g.dart b/lib/screens/accounts/models/account_model.g.dart new file mode 100644 index 0000000..fa546f8 --- /dev/null +++ b/lib/screens/accounts/models/account_model.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'account_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CurrencyModel _$CurrencyModelFromJson(Map 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 _$CurrencyModelToJson(CurrencyModel instance) => + { + 'id': instance.id, + 'createdAt': instance.createdAt, + 'name': instance.name, + 'symbol': instance.symbol, + 'code': instance.code, + 'rate': instance.rate, + 'defaultCurrency': instance.defaultCurrency, + }; + +AccountModel _$AccountModelFromJson(Map 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), + customerStringId: json['customerStringId'] as String, + companyStringId: json['companyStringId'] as String, +); + +Map _$AccountModelToJson(AccountModel instance) => + { + '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, + }; diff --git a/lib/screens/accounts/models/statement_model.dart b/lib/screens/accounts/models/statement_model.dart new file mode 100644 index 0000000..134a666 --- /dev/null +++ b/lib/screens/accounts/models/statement_model.dart @@ -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 json) => + _$TransactionModelFromJson(json); + + Map toJson() => _$TransactionModelToJson(this); +} + +@JsonSerializable() +class StatementModel { + final int startBalance; + final double endBalance; + final List 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 json) => + _$StatementModelFromJson(json); + + Map toJson() => _$StatementModelToJson(this); +} \ No newline at end of file diff --git a/lib/screens/accounts/models/statement_model.g.dart b/lib/screens/accounts/models/statement_model.g.dart new file mode 100644 index 0000000..420f8a1 --- /dev/null +++ b/lib/screens/accounts/models/statement_model.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'statement_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TransactionModel _$TransactionModelFromJson(Map 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 _$TransactionModelToJson(TransactionModel instance) => + { + '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 json) => + StatementModel( + startBalance: (json['startBalance'] as num).toInt(), + endBalance: (json['endBalance'] as num).toDouble(), + transactions: (json['transactions'] as List) + .map((e) => TransactionModel.fromJson(e as Map)) + .toList(), + accountName: json['accountName'] as String, + accountNumber: json['accountNumber'] as String, + startDate: json['startDate'] as String, + endDate: json['endDate'] as String, + ); + +Map _$StatementModelToJson(StatementModel instance) => + { + 'startBalance': instance.startBalance, + 'endBalance': instance.endBalance, + 'transactions': instance.transactions, + 'accountName': instance.accountName, + 'accountNumber': instance.accountNumber, + 'startDate': instance.startDate, + 'endDate': instance.endDate, + }; diff --git a/lib/screens/accounts/widgets/account_balance_widget.dart b/lib/screens/accounts/widgets/account_balance_widget.dart new file mode 100644 index 0000000..f545fb1 --- /dev/null +++ b/lib/screens/accounts/widgets/account_balance_widget.dart @@ -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 createState() => _AccountBalanceWidgetState(); +} + +class _AccountBalanceWidgetState extends State + 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 _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 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)}'; + } +} \ No newline at end of file diff --git a/lib/screens/confirm/confirm_controller.dart b/lib/screens/confirm/confirm_controller.dart index fed2a7d..0e5e1de 100644 --- a/lib/screens/confirm/confirm_controller.dart +++ b/lib/screens/confirm/confirm_controller.dart @@ -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( diff --git a/lib/screens/confirm/confirm_screen.dart b/lib/screens/confirm/confirm_screen.dart index b479e07..90b598e 100644 --- a/lib/screens/confirm/confirm_screen.dart +++ b/lib/screens/confirm/confirm_screen.dart @@ -134,94 +134,167 @@ class _ConfirmScreenState extends State } } - Future _showOtpBottomSheet() { + Future _showOtpBottomSheet() async { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; final otpController = TextEditingController(); final formKey = GlobalKey(); return showModalBottomSheet( 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, + ), + ), + ), + ), + ], + ), ), ), ); diff --git a/lib/screens/groups/batch_create_screen.dart b/lib/screens/groups/batch_create_screen.dart index 521eec8..5a97063 100644 --- a/lib/screens/groups/batch_create_screen.dart +++ b/lib/screens/groups/batch_create_screen.dart @@ -103,10 +103,7 @@ class _BatchCreateScreenState extends State { 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.'); } } diff --git a/lib/screens/groups/batch_detail_screen.dart b/lib/screens/groups/batch_detail_screen.dart index d809667..bf6c5fa 100644 --- a/lib/screens/groups/batch_detail_screen.dart +++ b/lib/screens/groups/batch_detail_screen.dart @@ -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 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 _headerAnimController.dispose(); _cardAnimController.dispose(); _contentAnimController.dispose(); + _confirmController?.dispose(); controller.dispose(); super.dispose(); } @@ -168,6 +172,9 @@ class _BatchDetailScreenState extends State 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 Future _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 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 _showOtpBottomSheet() async { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final otpController = TextEditingController(); + final formKey = GlobalKey(); + + return showModalBottomSheet( + 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 _onPoll() async { final batch = _batch; if (batch == null) return; @@ -678,65 +890,6 @@ class _BatchDetailScreenState extends State ); } - // ────────────────────────────────────────────────────────────── - // 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 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 ), const SizedBox(height: 16), _buildDottedDivider(isDark), - ..._buildHeaderActionButtons(batch, theme), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [..._buildHeaderActionButtons(batch, theme)], + ), ], ), ); diff --git a/lib/screens/groups/controllers/batch_controller.dart b/lib/screens/groups/controllers/batch_controller.dart index 9e58caa..b604649 100644 --- a/lib/screens/groups/controllers/batch_controller.dart +++ b/lib/screens/groups/controllers/batch_controller.dart @@ -403,7 +403,7 @@ class BatchController extends ChangeNotifier { } } - Future> pollBatch( + Future> 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); - model.currentBatch = batch; + final batchTransaction = TransactionModel.fromJson( + response as Map, + ); 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); diff --git a/lib/screens/groups/group_detail_screen.dart b/lib/screens/groups/group_detail_screen.dart index 015f82c..2860b3d 100644 --- a/lib/screens/groups/group_detail_screen.dart +++ b/lib/screens/groups/group_detail_screen.dart @@ -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 return Column( children: [ _buildMembersSheetHandle(), - _buildMembersSheetHeader( - setSheetState, - scrollController, - ), + _buildMembersSheetHeader(setSheetState, scrollController), Expanded( child: ListenableBuilder( listenable: groupController, @@ -119,9 +117,7 @@ class _GroupDetailScreenState extends State 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 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 }, 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 Widget _buildAnimatedMemberCard(int index, RecipientGroupMember member) { final clampedIndex = index.clamp(0, 5); - final animation = Tween( - 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(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 ], ), 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 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 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 ); } - 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 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 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 Widget _buildMemberPopupMenu(RecipientGroupMember member) { return PopupMenuButton( - 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 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 final confirmed = await showDialog( 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 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 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 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 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 ); } - 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, + ), ), - ), + ], ); } diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index 91c3e30..6cc90eb 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -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 with TickerProviderStateMixin { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!_isLoggedIn) _buildLoginPrompt(theme, isDark), + if (_isLoggedIn) const AccountBalanceWidget(), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/lib/widgets/status_chip_widget.dart b/lib/widgets/status_chip_widget.dart index 4e965b8..1f01fbf 100644 --- a/lib/widgets/status_chip_widget.dart +++ b/lib/widgets/status_chip_widget.dart @@ -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 _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, + ), + ), + ], ), ); }