added wallet functionality
This commit is contained in:
@@ -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://192.168.100.138:6950/api
|
||||||
; BASE_URL=http://173.212.247.232:6950/api
|
; BASE_URL=http://173.212.247.232:6950/api
|
||||||
; BASE_URL=http://10.69.5.201:6950/api
|
; BASE_URL=http://10.69.5.201:6950/api
|
||||||
|
|||||||
@@ -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
|
CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com
|
||||||
SIMULATE_PAYMENT_SUCCESS=false
|
SIMULATE_PAYMENT_SUCCESS=false
|
||||||
GATEWAY_SCRIPT_URL=https://na.gateway.mastercard.com/static/checkout/checkout.min.js
|
GATEWAY_SCRIPT_URL=https://na.gateway.mastercard.com/static/checkout/checkout.min.js
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
var logger = Logger(printer: PrettyPrinter());
|
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 {
|
Future<dynamic> get(String url) async {
|
||||||
|
Map<String, String> headers = await getHeaders();
|
||||||
|
|
||||||
Response response;
|
Response response;
|
||||||
response = await dio.get(baseUrl + url);
|
response = await dio.get(baseUrl + url, options: Options(headers: headers));
|
||||||
logger.i(response.data.toString());
|
logger.i(response.data.toString());
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -45,7 +59,7 @@ class Http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> put(String url, dynamic data) async {
|
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 response;
|
||||||
response = await dio.put(
|
response = await dio.put(
|
||||||
@@ -58,8 +72,13 @@ class Http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> delete(String url) async {
|
Future<dynamic> delete(String url) async {
|
||||||
|
Map<String, String> headers = await getHeaders();
|
||||||
|
|
||||||
Response response;
|
Response response;
|
||||||
response = await dio.delete(baseUrl + url);
|
response = await dio.delete(
|
||||||
|
baseUrl + url,
|
||||||
|
options: Options(headers: headers),
|
||||||
|
);
|
||||||
logger.i(response.data.toString());
|
logger.i(response.data.toString());
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
67
lib/screens/accounts/models/account_model.dart
Normal file
67
lib/screens/accounts/models/account_model.dart
Normal 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);
|
||||||
|
}
|
||||||
62
lib/screens/accounts/models/account_model.g.dart
Normal file
62
lib/screens/accounts/models/account_model.g.dart
Normal 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,
|
||||||
|
};
|
||||||
63
lib/screens/accounts/models/statement_model.dart
Normal file
63
lib/screens/accounts/models/statement_model.dart
Normal 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);
|
||||||
|
}
|
||||||
61
lib/screens/accounts/models/statement_model.g.dart
Normal file
61
lib/screens/accounts/models/statement_model.g.dart
Normal 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,
|
||||||
|
};
|
||||||
390
lib/screens/accounts/widgets/account_balance_widget.dart
Normal file
390
lib/screens/accounts/widgets/account_balance_widget.dart
Normal 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)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,7 +165,10 @@ class ConfirmController extends ChangeNotifier {
|
|||||||
"Network error. Please try again or contact support",
|
"Network error. Please try again or contact support",
|
||||||
);
|
);
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
logger.e(e);
|
||||||
logger.e(s);
|
logger.e(s);
|
||||||
|
}
|
||||||
model.isLoading = false;
|
model.isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return ApiResponse.failure(
|
return ApiResponse.failure(
|
||||||
|
|||||||
@@ -134,25 +134,29 @@ 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 otpController = TextEditingController();
|
||||||
final formKey = GlobalKey<FormState>();
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
return showModalBottomSheet<String>(
|
return showModalBottomSheet<String>(
|
||||||
context: context,
|
context: context,
|
||||||
isDismissible: false,
|
isScrollControlled: true,
|
||||||
enableDrag: false,
|
backgroundColor: Colors.transparent,
|
||||||
shape: const RoundedRectangleBorder(
|
builder: (sheetContext) {
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
||||||
),
|
|
||||||
builder: (context) {
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
left: 24,
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
||||||
right: 24,
|
|
||||||
top: 24,
|
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
|
||||||
),
|
),
|
||||||
|
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(
|
child: Form(
|
||||||
key: formKey,
|
key: formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -163,67 +167,136 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 4,
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
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(
|
Text(
|
||||||
'OTP Verification',
|
'OTP Verification',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w700,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
),
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Please enter the OTP sent to your mobile/email',
|
'An OTP has been sent to your registered mobile number/email. Please enter it below to proceed.',
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: otpController,
|
controller: otpController,
|
||||||
keyboardType: TextInputType.number,
|
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(
|
decoration: InputDecoration(
|
||||||
labelText: 'Enter OTP',
|
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(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
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) {
|
validator: (value) {
|
||||||
if (value == null || value.trim().isEmpty) {
|
if (value == null || value.trim().isEmpty) {
|
||||||
return 'Please enter the OTP';
|
return 'Please enter the OTP';
|
||||||
}
|
}
|
||||||
|
if (value.trim().length < 4) {
|
||||||
|
return 'OTP must be at least 4 digits';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (formKey.currentState!.validate()) {
|
if (formKey.currentState?.validate() ?? false) {
|
||||||
Navigator.of(context).pop(otpController.text.trim());
|
Navigator.of(
|
||||||
|
sheetContext,
|
||||||
|
).pop(otpController.text.trim());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
backgroundColor: theme.colorScheme.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
|
elevation: 0,
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Submit OTP',
|
'Submit',
|
||||||
style: TextStyle(fontSize: 16),
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -103,10 +103,7 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|||||||
final batch = result.data!;
|
final batch = result.data!;
|
||||||
context.pushReplacement('/groups/${widget.group.id}/batches/${batch.id}');
|
context.pushReplacement('/groups/${widget.group.id}/batches/${batch.id}');
|
||||||
} else {
|
} else {
|
||||||
AppSnackBar.showError(
|
AppSnackBar.showError(context, result.error ?? 'Failed to create batch.');
|
||||||
context,
|
|
||||||
result.error ?? 'Failed to create batch.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.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/screens/groups/models/group_batch_model.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
||||||
@@ -27,6 +28,8 @@ class BatchDetailScreen extends StatefulWidget {
|
|||||||
class _BatchDetailScreenState extends State<BatchDetailScreen>
|
class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||||
with TickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
late BatchController controller;
|
late BatchController controller;
|
||||||
|
ConfirmController? _confirmController;
|
||||||
|
GroupBatchUpdateRequest? _cachedUpdateReq;
|
||||||
bool _showPollButton = false;
|
bool _showPollButton = false;
|
||||||
String _userId = '';
|
String _userId = '';
|
||||||
int _selectedTabIndex = 0;
|
int _selectedTabIndex = 0;
|
||||||
@@ -140,6 +143,7 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
_headerAnimController.dispose();
|
_headerAnimController.dispose();
|
||||||
_cardAnimController.dispose();
|
_cardAnimController.dispose();
|
||||||
_contentAnimController.dispose();
|
_contentAnimController.dispose();
|
||||||
|
_confirmController?.dispose();
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -168,6 +172,9 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache the update request so _onRequest can check the processor
|
||||||
|
_cachedUpdateReq = updateReq;
|
||||||
|
|
||||||
final result = await controller.confirmBatch(batch.id!, _userId);
|
final result = await controller.confirmBatch(batch.id!, _userId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (!result.isSuccess) {
|
if (!result.isSuccess) {
|
||||||
@@ -446,21 +453,54 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
Future<void> _onRequest() async {
|
Future<void> _onRequest() async {
|
||||||
final batch = _batch;
|
final batch = _batch;
|
||||||
if (batch == null) return;
|
if (batch == null) return;
|
||||||
|
|
||||||
final result = await controller.requestBatch(batch.id!, _userId);
|
final result = await controller.requestBatch(batch.id!, _userId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
await _init(); // refresh batch and items after request
|
|
||||||
|
|
||||||
if (!result.isSuccess) {
|
if (!result.isSuccess) {
|
||||||
_showError(result.error ?? 'Request failed.');
|
_showError(result.error ?? 'Request failed.');
|
||||||
return;
|
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 txn = result.data!;
|
||||||
final pollingStatus = txn.pollingStatus?.toUpperCase();
|
final pollingStatus = txn.pollingStatus?.toUpperCase();
|
||||||
|
|
||||||
if (pollingStatus != null && _terminalStatuses.contains(pollingStatus)) {
|
if (pollingStatus != null && _terminalStatuses.contains(pollingStatus)) {
|
||||||
await _init(); // refresh batch and items after request
|
await _init();
|
||||||
if (_successStatuses.contains(pollingStatus)) {
|
if (_successStatuses.contains(pollingStatus)) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
AppSnackBar.showSuccess(context, 'Payment completed successfully.');
|
AppSnackBar.showSuccess(context, 'Payment completed successfully.');
|
||||||
@@ -473,13 +513,185 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
setState(() => _showPollButton = false);
|
setState(() => _showPollButton = false);
|
||||||
} else {
|
} else {
|
||||||
setState(() => _showPollButton = true);
|
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 {
|
Future<void> _onPoll() async {
|
||||||
final batch = _batch;
|
final batch = _batch;
|
||||||
if (batch == null) return;
|
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)
|
// Refresh Button (sits next to status pill; reloads the batch)
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
@@ -836,7 +989,7 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_buildStatusBadge(status),
|
StatusChip(status: status),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_buildRefreshButton(theme, isDark),
|
_buildRefreshButton(theme, isDark),
|
||||||
],
|
],
|
||||||
@@ -878,7 +1031,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildDottedDivider(isDark),
|
_buildDottedDivider(isDark),
|
||||||
..._buildHeaderActionButtons(batch, theme),
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [..._buildHeaderActionButtons(batch, theme)],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ class BatchController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ApiResponse<GroupBatch>> pollBatch(
|
Future<ApiResponse<TransactionModel>> pollBatch(
|
||||||
String batchId,
|
String batchId,
|
||||||
String userId,
|
String userId,
|
||||||
) async {
|
) async {
|
||||||
@@ -418,11 +418,18 @@ class BatchController extends ChangeNotifier {
|
|||||||
body.toJson(),
|
body.toJson(),
|
||||||
);
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
|
final batchTransaction = TransactionModel.fromJson(
|
||||||
model.currentBatch = batch;
|
response as Map<String, dynamic>,
|
||||||
|
);
|
||||||
model.isActing = false;
|
model.isActing = false;
|
||||||
if (!_disposed) notifyListeners();
|
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) {
|
} catch (e, s) {
|
||||||
logger.e(e);
|
logger.e(e);
|
||||||
logger.e(s);
|
logger.e(s);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:qpay/screens/groups/models/group_batch_model.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/screens/groups/models/recipient_group_model.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
||||||
@@ -108,10 +109,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_buildMembersSheetHandle(),
|
_buildMembersSheetHandle(),
|
||||||
_buildMembersSheetHeader(
|
_buildMembersSheetHeader(setSheetState, scrollController),
|
||||||
setSheetState,
|
|
||||||
scrollController,
|
|
||||||
),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListenableBuilder(
|
child: ListenableBuilder(
|
||||||
listenable: groupController,
|
listenable: groupController,
|
||||||
@@ -119,9 +117,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
final allMembers = groupController.model.members;
|
final allMembers = groupController.model.members;
|
||||||
final filtered = _filteredMembers(allMembers);
|
final filtered = _filteredMembers(allMembers);
|
||||||
if (groupController.model.isLoading) {
|
if (groupController.model.isLoading) {
|
||||||
return _buildMembersSkeletonList(
|
return _buildMembersSkeletonList(scrollController);
|
||||||
scrollController,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (allMembers.isEmpty) {
|
if (allMembers.isEmpty) {
|
||||||
return _buildMembersEmptyState();
|
return _buildMembersEmptyState();
|
||||||
@@ -185,10 +181,9 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context)
|
color: Theme.of(
|
||||||
.colorScheme
|
context,
|
||||||
.primary
|
).colorScheme.primary.withValues(alpha: 0.1),
|
||||||
.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -237,10 +232,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
},
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search members...',
|
hintText: 'Search members...',
|
||||||
hintStyle: TextStyle(
|
hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14),
|
||||||
color: Colors.grey.shade500,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
prefixIcon: Icon(
|
prefixIcon: Icon(
|
||||||
Icons.search,
|
Icons.search,
|
||||||
color: Colors.grey.shade600,
|
color: Colors.grey.shade600,
|
||||||
@@ -301,10 +293,8 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
|
|
||||||
Widget _buildAnimatedMemberCard(int index, RecipientGroupMember member) {
|
Widget _buildAnimatedMemberCard(int index, RecipientGroupMember member) {
|
||||||
final clampedIndex = index.clamp(0, 5);
|
final clampedIndex = index.clamp(0, 5);
|
||||||
final animation = Tween<Offset>(
|
final animation =
|
||||||
begin: const Offset(0, 0.06),
|
Tween<Offset>(begin: const Offset(0, 0.06), end: Offset.zero).animate(
|
||||||
end: Offset.zero,
|
|
||||||
).animate(
|
|
||||||
CurvedAnimation(
|
CurvedAnimation(
|
||||||
parent: _membersAnimController,
|
parent: _membersAnimController,
|
||||||
curve: Interval(
|
curve: Interval(
|
||||||
@@ -350,10 +340,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 14),
|
||||||
vertical: 14,
|
|
||||||
horizontal: 14,
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -411,10 +398,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Theme.of(context).colorScheme.primary,
|
Theme.of(context).colorScheme.primary,
|
||||||
Theme.of(context)
|
Theme.of(context).colorScheme.primary.withValues(alpha: 0.7),
|
||||||
.colorScheme
|
|
||||||
.primary
|
|
||||||
.withValues(alpha: 0.7),
|
|
||||||
],
|
],
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
@@ -422,10 +406,9 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Theme.of(context)
|
color: Theme.of(
|
||||||
.colorScheme
|
context,
|
||||||
.primary
|
).colorScheme.primary.withValues(alpha: 0.25),
|
||||||
.withValues(alpha: 0.25),
|
|
||||||
blurRadius: 6,
|
blurRadius: 6,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -444,15 +427,13 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMemberSubtitleRow(
|
Widget _buildMemberSubtitleRow(Recipient recipient, String? memberAccount) {
|
||||||
Recipient recipient,
|
|
||||||
String? memberAccount,
|
|
||||||
) {
|
|
||||||
final account = memberAccount?.isNotEmpty == true
|
final account = memberAccount?.isNotEmpty == true
|
||||||
? memberAccount
|
? memberAccount
|
||||||
: recipient.account;
|
: recipient.account;
|
||||||
final hasAccount = account != null && account.isNotEmpty;
|
final hasAccount = account != null && account.isNotEmpty;
|
||||||
final hasProvider = recipient.latestProviderLabel != null &&
|
final hasProvider =
|
||||||
|
recipient.latestProviderLabel != null &&
|
||||||
recipient.latestProviderLabel!.isNotEmpty;
|
recipient.latestProviderLabel!.isNotEmpty;
|
||||||
if (!hasAccount && !hasProvider) {
|
if (!hasAccount && !hasProvider) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
@@ -464,15 +445,11 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
children: [
|
children: [
|
||||||
if (hasProvider)
|
if (hasProvider)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
horizontal: 8,
|
|
||||||
vertical: 2,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context)
|
color: Theme.of(
|
||||||
.colorScheme
|
context,
|
||||||
.primary
|
).colorScheme.primary.withValues(alpha: 0.08),
|
||||||
.withValues(alpha: 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -507,10 +484,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@@ -521,17 +495,11 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
|
|
||||||
Widget _buildMemberPopupMenu(RecipientGroupMember member) {
|
Widget _buildMemberPopupMenu(RecipientGroupMember member) {
|
||||||
return PopupMenuButton<String>(
|
return PopupMenuButton<String>(
|
||||||
icon: Icon(
|
icon: Icon(Icons.more_vert, color: Colors.grey.shade500, size: 20),
|
||||||
Icons.more_vert,
|
|
||||||
color: Colors.grey.shade500,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
splashRadius: 20,
|
splashRadius: 20,
|
||||||
tooltip: 'More options',
|
tooltip: 'More options',
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
if (value == 'remove') {
|
if (value == 'remove') {
|
||||||
await _confirmRemoveMember(member);
|
await _confirmRemoveMember(member);
|
||||||
@@ -542,8 +510,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
value: 'remove',
|
value: 'remove',
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.person_remove_outlined,
|
Icon(Icons.person_remove_outlined, size: 18, color: Colors.red),
|
||||||
size: 18, color: Colors.red),
|
|
||||||
SizedBox(width: 10),
|
SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
'Remove from group',
|
'Remove from group',
|
||||||
@@ -564,9 +531,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
title: const Text('Remove member'),
|
title: const Text('Remove member'),
|
||||||
content: Text('Are you sure you want to remove $name from this group?'),
|
content: Text('Are you sure you want to remove $name from this group?'),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -628,10 +593,9 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
width: 88,
|
width: 88,
|
||||||
height: 88,
|
height: 88,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context)
|
color: Theme.of(
|
||||||
.colorScheme
|
context,
|
||||||
.primary
|
).colorScheme.primary.withValues(alpha: 0.08),
|
||||||
.withValues(alpha: 0.08),
|
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -690,10 +654,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
Text(
|
Text(
|
||||||
'No results for "$_memberQuery"',
|
'No results for "$_memberQuery"',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||||
fontSize: 13,
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1163,6 +1124,8 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
final inSelectMode = batchController.model.selectMode;
|
final inSelectMode = batchController.model.selectMode;
|
||||||
|
|
||||||
final status = batch.status ?? 'UNKNOWN';
|
final status = batch.status ?? 'UNKNOWN';
|
||||||
|
final createdAt = batch.createdAt;
|
||||||
|
final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null;
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -1227,27 +1190,46 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
color: Colors.grey.shade600,
|
color: Colors.grey.shade600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (batch.count != null)
|
if (batch.count != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_countPill('${batch.count}', Colors.blue),
|
_statIcon(
|
||||||
const SizedBox(width: 3),
|
Icons.people_outline,
|
||||||
_countPill(
|
'${batch.count}',
|
||||||
'${batch.successfulCount ?? 0} ok',
|
|
||||||
Colors.green,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 3),
|
const SizedBox(width: 4),
|
||||||
_countPill(
|
_statIcon(
|
||||||
'${batch.failedCount ?? 0} failed',
|
Icons.check_circle_outline,
|
||||||
Colors.red,
|
'${batch.successfulCount ?? 0}',
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_statIcon(
|
||||||
|
Icons.cancel_outlined,
|
||||||
|
'${batch.failedCount ?? 0}',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (!inSelectMode)
|
if (!inSelectMode)
|
||||||
Icon(Icons.chevron_right, color: Colors.grey.shade400),
|
Icon(Icons.chevron_right, color: Colors.grey.shade400),
|
||||||
@@ -1258,22 +1240,21 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _countPill(String label, Color color) {
|
Widget _statIcon(IconData icon, String label) {
|
||||||
return Container(
|
return Row(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
mainAxisSize: MainAxisSize.min,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: color.withAlpha(20),
|
Icon(icon, size: 12, color: Colors.grey.shade500),
|
||||||
borderRadius: BorderRadius.circular(4),
|
const SizedBox(width: 3),
|
||||||
border: Border.all(color: color.withAlpha(60)),
|
Text(
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: color,
|
color: Colors.grey.shade600,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:qpay/auth_state.dart';
|
import 'package:qpay/auth_state.dart';
|
||||||
import 'package:qpay/models/responsive_policy.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/home/home_controller.dart';
|
||||||
import 'package:qpay/screens/receipt/receipt_controller.dart';
|
import 'package:qpay/screens/receipt/receipt_controller.dart';
|
||||||
import 'package:qpay/screens/transaction_controller.dart';
|
import 'package:qpay/screens/transaction_controller.dart';
|
||||||
@@ -364,6 +365,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (!_isLoggedIn) _buildLoginPrompt(theme, isDark),
|
if (!_isLoggedIn) _buildLoginPrompt(theme, isDark),
|
||||||
|
if (_isLoggedIn) const AccountBalanceWidget(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
|||||||
@@ -5,40 +5,75 @@ class StatusChip extends StatelessWidget {
|
|||||||
|
|
||||||
const StatusChip({super.key, required this.status});
|
const StatusChip({super.key, required this.status});
|
||||||
|
|
||||||
Color _color() {
|
static const _successStatuses = {'SUCCESS', 'COMPLETE', 'COMPLETED'};
|
||||||
switch (status.toUpperCase()) {
|
static const _processingStatuses = {'PROCESSING', 'REQUESTED', 'CONFIRMING'};
|
||||||
case 'SUCCESS':
|
static const _pendingStatuses = {'CREATED', 'CONFIRMED_ALL'};
|
||||||
case 'COMPLETE':
|
|
||||||
return Colors.green;
|
Map<String, dynamic> _getStatusColors() {
|
||||||
case 'FAILED':
|
final upper = status.toUpperCase();
|
||||||
return Colors.red;
|
final bool isSuccess = _successStatuses.contains(upper);
|
||||||
case 'PROCESSING':
|
final bool isProcessing = _processingStatuses.contains(upper);
|
||||||
return Colors.orange;
|
final bool isPending = _pendingStatuses.contains(upper);
|
||||||
case 'CONFIRMED':
|
|
||||||
return Colors.blue;
|
final Color bgColor = isSuccess
|
||||||
default:
|
? const Color(0xFFD1FAE5)
|
||||||
return Colors.grey;
|
: 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withAlpha(25),
|
color: bgColor,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: color.withAlpha(80)),
|
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
status,
|
status,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: color,
|
color: textColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user