haveno-app/lib/views/screens/taker_offer_detail_screen.dart
2024-12-08 06:38:57 +00:00

469 lines
21 KiB
Dart

// Haveno App extends the features of Haveno, supporting mobile devices and more.
// Copyright (C) 2024 Kewbit (https://kewbit.org)
// Source Code: https://git.haveno.com/haveno/haveno-app.git
//
// Author: Kewbit
// Website: https://kewbit.org
// Contact Email: me@kewbit.org
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import 'package:fixnum/fixnum.dart' as fixnum;
import 'package:flutter/material.dart';
import 'package:grpc/grpc.dart';
import 'package:haveno/grpc_models.dart';
import 'package:haveno/profobuf_models.dart';
import 'package:haveno_app/providers/haveno_client_providers/price_provider.dart';
import 'package:haveno_app/views/screens/home_screen.dart';
import 'package:haveno_app/utils/payment_utils.dart';
import 'package:provider/provider.dart';
import 'package:haveno_app/providers/haveno_client_providers/trades_provider.dart';
import 'package:haveno_app/providers/haveno_client_providers/payment_accounts_provider.dart';
import 'package:haveno_app/views/widgets/loading_button.dart';
class OfferDetailScreen extends StatefulWidget {
final OfferInfo offer;
const OfferDetailScreen({super.key, required this.offer});
@override
_OfferDetailScreenState createState() => _OfferDetailScreenState();
}
class _OfferDetailScreenState extends State<OfferDetailScreen> {
final _formKey = GlobalKey<FormState>();
final TextEditingController _payController = TextEditingController();
final TextEditingController _receiveController = TextEditingController();
final TextEditingController _marketPriceController = TextEditingController(); // Controller for market price display
bool _isLoading = true;
bool _isWithinLimits = true;
OfferInfo? _offer;
String? _selectedPaymentAccountId;
List<PaymentAccount> _paymentAccounts = [];
double _currentMarketPrice = 0.0; // Variable to store the market price
@override
void initState() {
super.initState();
_offer = widget.offer;
_loadPaymentAccounts();
_loadMarketPrices();
_initializeReceiveAmount();
_payController.addListener(_updateReceiveAmount);
_receiveController.addListener(_updatePayAmount);
}
// Determine if the trade is selling XMR or buying XMR
bool get isSellingXMR => !_offer!.isMyOffer && _offer!.direction == 'BUY';
// Initialize the receive/pay amounts based on the minimum offer amount
void _initializeReceiveAmount() {
if (_offer != null) {
final minAmountXMR = _offer!.minAmount.toDouble() / 1e12; // Convert from atomic units
if (isSellingXMR) {
// Selling XMR: Prefill "I will pay" with min XMR
_payController.text = minAmountXMR.toStringAsFixed(12);
_updateReceiveAmount();
} else {
// Buying XMR: Prefill "I will receive" with min XMR
_receiveController.text = minAmountXMR.toStringAsFixed(12);
_updatePayAmount();
}
}
}
void _loadMarketPrices() async {
final pricesProvider = Provider.of<PricesProvider>(context, listen: false);
final marketPrice = pricesProvider.prices.firstWhere(
(price) => price.currencyCode == _offer?.counterCurrencyCode,
orElse: () => MarketPriceInfo(currencyCode: 'USD', price: 0),
);
setState(() {
_currentMarketPrice = marketPrice.price;
_marketPriceController.text = _calculateMarketValue().toStringAsFixed(2);
});
}
// Updates the receive amount based on the pay amount
void _updateReceiveAmount() {
if (_offer != null && _payController.text.isNotEmpty) {
final payAmount = double.tryParse(_payController.text) ?? 0;
final offerPrice = double.parse(_offer!.price);
_receiveController.removeListener(_updatePayAmount);
if (isSellingXMR) {
// Selling XMR: Calculate receive amount in fiat
_receiveController.text = (payAmount * offerPrice).toStringAsFixed(2);
} else {
// Buying XMR: Calculate receive amount in XMR
_receiveController.text = (payAmount / offerPrice).toStringAsFixed(12);
}
_receiveController.addListener(_updatePayAmount);
_checkLimits();
_marketPriceController.text = _calculateMarketValue().toStringAsFixed(2);
}
}
// Updates the pay amount based on the receive amount
void _updatePayAmount() {
if (_offer != null && _receiveController.text.isNotEmpty) {
final receiveAmount = double.tryParse(_receiveController.text) ?? 0;
final offerPrice = double.parse(_offer!.price);
_payController.removeListener(_updateReceiveAmount);
if (isSellingXMR) {
// Selling XMR: Calculate pay amount in XMR
_payController.text = (receiveAmount / offerPrice).toStringAsFixed(12);
} else {
// Buying XMR: Calculate pay amount in fiat
_payController.text = (receiveAmount * offerPrice).toStringAsFixed(2);
}
_payController.addListener(_updateReceiveAmount);
_checkLimits();
_marketPriceController.text = _calculateMarketValue().toStringAsFixed(2);
}
}
double _calculateMarketValue() {
// Get the XMR amount based on whether it's a buy or sell trade
final xmrAmount = double.tryParse(isSellingXMR ? _payController.text : _receiveController.text) ?? 0;
// Calculate the equivalent fiat value of the XMR
return xmrAmount * _currentMarketPrice;
}
void _loadPaymentAccounts() async {
final paymentAccountsProvider =
Provider.of<PaymentAccountsProvider>(context, listen: false);
await paymentAccountsProvider.getPaymentAccounts();
setState(() {
_paymentAccounts = paymentAccountsProvider.paymentAccounts
.where((account) =>
account.paymentMethod.id == _offer?.paymentMethodId)
.toList() ??
[];
_isLoading = false;
});
}
// Ensures the pay/receive amounts are within the offer limits
void _checkLimits() {
final minAmountXMR = _offer!.minAmount.toDouble() / 1e12;
final maxAmountXMR = _offer!.amount.toDouble() / 1e12;
final xmrAmount = double.tryParse(isSellingXMR ? _payController.text : _receiveController.text) ?? 0;
setState(() {
_isWithinLimits = xmrAmount >= minAmountXMR && xmrAmount <= maxAmountXMR;
});
}
Future<void> _confirmOrder() async {
if (_formKey.currentState?.validate() ?? false) {
final tradesProvider =
Provider.of<TradesProvider>(context, listen: false);
try {
// Declare variables for handling amounts
BigInt amountBigInt;
// Check if user is selling or buying
if (!isSellingXMR) {
// User is buying XMR: use receiveAmount
final receiveAmountDouble = double.parse(_receiveController.text);
amountBigInt = BigInt.from((receiveAmountDouble * 1e12).round());
} else {
// User is selling XMR: use payAmount
final payAmountDouble = double.parse(_payController.text);
amountBigInt = BigInt.from((payAmountDouble * 1e12).round());
}
// Call the takeOffer function with the correct amount
await tradesProvider.takeOffer(
widget.offer.id,
_selectedPaymentAccountId, // Use selected payment account ID
fixnum.Int64(amountBigInt.toInt()), // Use the calculated BigInt as Int64
);
// Navigate to HomeScreen after successful order confirmation
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => HomeScreen(initialIndex: 3),
),
);
} on GrpcError catch (e) {
// Handle error: Navigate to a different screen and show error message
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => HomeScreen(initialIndex: 1),
),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.message ?? 'Unknown server error'),
),
);
}
}
}
@override
void dispose() {
_payController.dispose();
_receiveController.dispose();
_marketPriceController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(isSellingXMR ? "Sell Your Monero" : "Buy Monero"),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _offer == null
? const Center(child: Text('Offer not found'))
: Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
children: <Widget>[
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Offer Details',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold)),
const SizedBox(height: 16.0),
// Render the text fields based on the trade direction
if (isSellingXMR) ...[
TextFormField(
controller: _payController,
decoration: const InputDecoration(
labelText: 'I will pay',
suffixText: 'XMR',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter the amount to pay';
}
return null;
},
),
const SizedBox(height: 16.0),
TextFormField(
controller: _receiveController,
decoration: InputDecoration(
labelText: 'I will receive',
suffixText: _offer?.counterCurrencyCode,
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter the amount to receive';
}
return null;
},
),
] else ...[
TextFormField(
controller: _payController,
decoration: InputDecoration(
labelText: 'I will pay',
suffixText: _offer?.counterCurrencyCode,
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter the amount to pay';
}
return null;
},
),
const SizedBox(height: 16.0),
TextFormField(
controller: _receiveController,
decoration: const InputDecoration(
labelText: 'I will receive',
suffixText: 'XMR',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter the amount to receive';
}
return null;
},
),
],
const SizedBox(height: 16.0),
// Market value approximation
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Center(
child: Text(
_marketPriceController.text.isEmpty
? 'Calculating...'
: 'XMR is ~${_marketPriceController.text} ${_offer?.counterCurrencyCode} in Market Value',
style: TextStyle(
fontSize: 16.0,
color: Colors.grey[600]),
),
)
],
),
if (!_isWithinLimits)
const Padding(
padding: EdgeInsets.only(top: 8.0),
child: Text(
'Amount is out of the buy limits',
style: TextStyle(color: Colors.red),
),
),
],
),
),
),
const SizedBox(height: 4.0),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${(!_offer!.isMyOffer && _offer!.direction == 'BUY') ? "Paid" : "Pay"} via ${_offer?.paymentMethodShortName}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold)),
const SizedBox(height: 16.0),
DropdownButtonFormField<String>(
decoration: InputDecoration(
labelText: (!_offer!.isMyOffer && _offer!.direction == 'BUY') ? "You'll receive to" : "You'll pay with",
border: const OutlineInputBorder(),
),
items: _paymentAccounts.map((account) {
return DropdownMenuItem<String>(
value: account.id,
child: Text(account.accountName),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedPaymentAccountId = value;
});
},
validator: (value) {
if (value == null) {
return 'Please select a payment account';
}
return null;
},
),
],
),
),
),
const SizedBox(height: 4.0),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('About this Offer',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold)),
const SizedBox(height: 16.0),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text((!_offer!.isMyOffer && _offer!.direction == 'BUY') ? "Buyer's Rate" : "Seller's Rate", style: TextStyle(fontWeight: FontWeight.bold)),
Text(isFiatCurrency(
_offer!.counterCurrencyCode)
? '${double.parse(_offer!.price).toStringAsFixed(2)} ${_offer!.counterCurrencyCode}/${_offer!.baseCurrencyCode}'
: _offer!.price),
],
),
const SizedBox(height: 16.0),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
const Text('Minimum Amount', style: TextStyle(fontWeight: FontWeight.bold)),
Text(
'${_offer!.minVolume} ${_offer!.counterCurrencyCode}'),
],
),
const SizedBox(height: 16.0),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
const Text('Maximum Amount', style: TextStyle(fontWeight: FontWeight.bold)),
Text(
'${_offer!.volume} ${_offer!.counterCurrencyCode}'),
],
),
const SizedBox(height: 16.0),
const Text('Offer ID', style: TextStyle(fontWeight: FontWeight.bold)),
Text(_offer!.id),
const SizedBox(height: 16.0),
const Text('Payment Method', style: TextStyle(fontWeight: FontWeight.bold)),
Text(_offer!.paymentMethodShortName),
],
),
),
),
],
),
),
),
),
bottomNavigationBar: Padding(
padding: const EdgeInsets.all(16.0),
child: LoadingButton(
onPressed: _confirmOrder,
child: const Text('Confirm Trade'),
),
),
);
}
}