diff --git a/lib/model/cex_provider.dart b/lib/model/cex_provider.dart index 35a81237b..ddbe8aed6 100644 --- a/lib/model/cex_provider.dart +++ b/lib/model/cex_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; import 'package:komodo_dex/blocs/coins_bloc.dart'; +import 'package:komodo_dex/packages/binance_candlestick_charts/bloc/binance_repository.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../app_config/app_config.dart'; @@ -87,56 +88,38 @@ class CexProvider extends ChangeNotifier { cexPrices.unlinkProvider(this); } - final String _chartsUrl = appConfig.candlestickData; - final Uri _tickersListUrl = Uri.parse(appConfig.candlestickTickersList); - final Map _charts = {}; // {'BTC-USD': ChartData(),} + final Map _charts = {}; bool _updatingChart = false; List _tickers; + final BinanceRepository _binanceRepository = BinanceRepository(); void _updateRates() => cexPrices.updateRates(); List _getTickers() { - if (_tickers != null) return _tickers; + if (_tickers != null) { + return _tickers; + } _updateTickersList(); return _tickersFallBack; } Future _updateTickersList() async { - http.Response _res; - String _body; try { - _res = await http.get(_tickersListUrl).timeout( - const Duration(seconds: 60), - onTimeout: () { - Log('cex_provider', 'Fetching tickers timed out'); - return; - }, - ); - _body = _res.body; + _tickers = await _binanceRepository.getLegacyTickers(); + notifyListeners(); } catch (e) { Log('cex_provider', 'Failed to fetch tickers list: $e'); } - - List json; - try { - json = jsonDecode(_body); - } catch (e) { - Log('cex_provider', 'Failed to parse tickers json: $e'); - } - - if (json != null) { - _tickers = - json.map((dynamic ticker) => ticker.toString()).toList(); - notifyListeners(); - } } Future _updateChart(String pair) async { if (_updatingChart) return; final List chain = _findChain(pair); - if (chain == null) throw 'No chart data available'; + if (chain == null) { + throw 'No chart data available'; + } Map json0; Map json1; @@ -178,7 +161,7 @@ class CexProvider extends ChangeNotifier { json0.forEach((String duration, dynamic list) { final List _durationData = []; - for (var candle in list) { + for (final Map candle in list) { double open = chain[0].reverse ? 1 / candle['open'].toDouble() : candle['open'].toDouble(); @@ -248,7 +231,6 @@ class CexProvider extends ChangeNotifier { } data[duration] = _durationData; - notifyListeners(); }); _charts[pair] = ChartData( @@ -258,58 +240,43 @@ class CexProvider extends ChangeNotifier { status: ChartStatus.success, updated: DateTime.now().millisecondsSinceEpoch, ); - - notifyListeners(); } Future> _fetchChartData(ChainLink link) async { - final String pair = '${link.rel}-${link.base}'; - http.Response _res; - String _body; try { - _res = await http - .get(Uri.parse('$_chartsUrl/${pair.toLowerCase()}')) - .timeout( - const Duration(seconds: 60), - onTimeout: () { - Log('cex_provider', 'Fetching $pair data timed out'); - throw 'Fetching $pair timed out'; - }, - ); - _body = _res.body; + final String pair = '${link.rel}-${link.base}'; + final Map result = + await _binanceRepository.getLegacyOhlcCandleData(pair); + return result; } catch (e) { Log('cex_provider', 'Failed to fetch data: $e'); rethrow; } - - Map json; - try { - json = jsonDecode(_body); - } catch (e) { - Log('cex_provider', 'Failed to parse json: $e'); - rethrow; - } - - return json; } List _findChain(String pair) { + // remove cex postfixes + pair = getCoinTickerRegex(pair); + final List abbr = pair.split('-'); - if (abbr[0] == abbr[1]) return null; + if (abbr[0] == abbr[1]) { + return null; + } final String base = abbr[1].toLowerCase(); final String rel = abbr[0].toLowerCase(); final List tickers = _getTickers(); List chain; - if (tickers == null) return null; + if (tickers == null) { + return null; + } // try to find simple chain, direct or reverse - for (String ticker in tickers) { + for (final String ticker in tickers) { final List availableAbbr = ticker.split('-'); if (!(availableAbbr.contains(rel) && availableAbbr.contains(base))) { continue; } - chain = [ ChainLink( rel: availableAbbr[0], @@ -317,20 +284,25 @@ class CexProvider extends ChangeNotifier { reverse: availableAbbr[0] != rel, ) ]; + break; } - if (chain != null) return chain; + if (chain != null) { + return chain; + } tickers.sort((String a, String b) { - if (a.toLowerCase().contains('btc') && !b.toLowerCase().contains('btc')) + if (a.toLowerCase().contains('btc') && !b.toLowerCase().contains('btc')) { return -1; - if (b.toLowerCase().contains('btc') && !a.toLowerCase().contains('btc')) + } + if (b.toLowerCase().contains('btc') && !a.toLowerCase().contains('btc')) { return 1; + } return 0; }); OUTER: - for (String firstLinkStr in tickers) { + for (final String firstLinkStr in tickers) { final List firstLinkCoins = firstLinkStr.split('-'); if (!firstLinkCoins.contains(rel) && !firstLinkCoins.contains(base)) { continue; @@ -344,7 +316,7 @@ class CexProvider extends ChangeNotifier { firstLink.reverse ? firstLink.rel : firstLink.base; final String secondBase = firstLinkCoins.contains(rel) ? base : rel; - for (String secondLink in tickers) { + for (final String secondLink in tickers) { final List secondLinkCoins = secondLink.split('-'); if (!(secondLinkCoins.contains(secondRel) && secondLinkCoins.contains(secondBase))) { @@ -364,7 +336,9 @@ class CexProvider extends ChangeNotifier { } } - if (chain != null) return chain; + if (chain != null) { + return chain; + } return null; } @@ -649,16 +623,18 @@ class CexPrices { // Some coins are presented in multiple networks, // like BAT-ERC20 and BAT-BEP20, but have same // coingeckoId and same usd price - final List coins = - (allCoins.where((coin) => getCoinTicker(coin.abbr) == ticker) ?? []) - .toList(); + final List coins = (allCoins.where( + (Coin coin) => getCoinTickerRegex(coin.abbr) == ticker, + ) ?? + []) + .toList(); // check if coin volume is enough - double minVolume = 10000; - double lastPrice = double.tryParse(pricesData['last_price']) ?? 0; - double volume24h = double.tryParse(pricesData['volume24h']) ?? 0; + const double minVolume = 10000; + final double lastPrice = double.tryParse(pricesData['last_price']) ?? 0; + final double volume24h = double.tryParse(pricesData['volume24h']) ?? 0; - for (Coin coin in coins) { + for (final Coin coin in coins) { final String coinAbbr = coin.abbr; if (coin.type == CoinType.smartChain) { // enough_volume for all smartChain tokens is always true :. proceed @@ -769,7 +745,9 @@ class CexPrices { } void _notifyListeners() { - for (CexProvider provider in _providers) provider.notify(); + for (final CexProvider provider in _providers) { + provider.notify(); + } } } diff --git a/lib/packages/binance_candlestick_charts/bloc/binance_provider.dart b/lib/packages/binance_candlestick_charts/bloc/binance_provider.dart new file mode 100644 index 000000000..40f6ba5b9 --- /dev/null +++ b/lib/packages/binance_candlestick_charts/bloc/binance_provider.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../models/binance_exchange_info.dart'; +import '../models/binance_klines.dart'; + +/// A provider class for fetching data from the Binance API. +class BinanceProvider { + BinanceProvider({this.apiUrl = 'https://api.binance.com/api/v3'}); + + /// The base URL for the Binance API. Defaults to 'https://api.binance.com/api/v3'. + final String apiUrl; + + /// Fetches candlestick chart data from Binance API. + /// + /// Retrieves the candlestick chart data for a specific symbol and interval from the Binance API. + /// Optionally, you can specify the start time, end time, and limit of the data to fetch. + /// + /// Parameters: + /// - [symbol]: The trading symbol for which to fetch the candlestick chart data. + /// - [interval]: The time interval for the candlestick chart data (e.g., '1m', '1h', '1d'). + /// - [startTime]: The start time (in milliseconds since epoch, Unix time) of the data range to fetch (optional). + /// - [endTime]: The end time (in milliseconds since epoch, Unix time) of the data range to fetch (optional). + /// - [limit]: The maximum number of data points to fetch (optional). Defaults to 500, maximum is 1000. + /// + /// Returns: + /// A [Future] that resolves to a [BinanceKlinesResponse] object containing the fetched candlestick chart data. + /// + /// Example usage: + /// ```dart + /// final BinanceKlinesResponse klines = await fetchKlines('BTCUSDT', '1h', limit: 100); + /// ``` + /// + /// Throws: + /// - [Exception] if the API request fails. + Future fetchKlines( + String symbol, + String interval, { + int startTime, + int endTime, + int limit, + }) async { + final Map queryParameters = { + 'symbol': symbol, + 'interval': interval, + if (startTime != null) 'startTime': startTime.toString(), + if (endTime != null) 'endTime': endTime.toString(), + if (limit != null) 'limit': limit.toString(), + }; + + final Uri uri = + Uri.parse('$apiUrl/klines').replace(queryParameters: queryParameters); + + final http.Response response = await http.get(uri); + if (response.statusCode == 200) { + return BinanceKlinesResponse.fromJson( + jsonDecode(response.body) as List, + ); + } else { + throw Exception( + 'Failed to load klines: ${response.statusCode} ${response.body}', + ); + } + } + + /// Fetches the exchange information from Binance. + /// + /// Returns a [Future] that resolves to a [BinanceExchangeInfoResponse] object. + /// Throws an [Exception] if the request fails. + Future fetchExchangeInfo() async { + final http.Response response = await http.get( + Uri.parse('$apiUrl/exchangeInfo'), + ); + + if (response.statusCode == 200) { + return BinanceExchangeInfoResponse.fromJson( + jsonDecode(response.body), + ); + } else { + throw Exception( + 'Failed to load symbols: ${response.statusCode} ${response.body}'); + } + } +} diff --git a/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart new file mode 100644 index 000000000..67858c637 --- /dev/null +++ b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart @@ -0,0 +1,135 @@ +// Using relative imports in this "package" to make it easier to track external +// dependencies when moving or copying this "package" to another project. +import 'dart:collection'; + +import './binance_provider.dart'; +import '../models/binance_exchange_info.dart'; +import '../models/binance_klines.dart'; + +// Declaring constants here to make this easier to copy & move around +String get binanceApiEndpoint => 'https://api.binance.com/api/v3'; +const Map defaultCandleIntervalsBinanceMap = { + '60': '1m', + '180': '3m', + '300': '5m', + '900': '15m', + '1800': '30m', + '3600': '1h', + '7200': '2h', + '14400': '4h', + '21600': '6h', + '43200': '12h', + '86400': '1d', + '259200': '3d', + '604800': '1w', +}; + +/// A repository class for interacting with the Binance API. +/// This class provides methods to fetch legacy tickers and OHLC candle data. +class BinanceRepository { + BinanceRepository({BinanceProvider binanceProvider}) + : _binanceProvider = + binanceProvider ?? BinanceProvider(apiUrl: binanceApiEndpoint); + + final BinanceProvider _binanceProvider; + + /// Retrieves a list of tickers in the lowercase, dash-separated format (e.g. eth-btc). + /// + /// If the [_symbols] list is not empty, it is returned immediately. + /// Otherwise, it fetches the exchange information from [_binanceProvider] + /// and converts the symbols to the legacy hyphenated lowercase format. + /// + /// Returns a list of tickers. + Future> getLegacyTickers() async { + final BinanceExchangeInfoResponse exchangeInfo = + await _binanceProvider.fetchExchangeInfo(); + if (exchangeInfo == null) { + return []; + } + + // The legacy candlestick implementation uses hyphenated lowercase + // symbols, so we convert the symbols to that format here. + final List _symbols = exchangeInfo.symbols + .where((Symbol symbol) => symbol.status.toUpperCase() == 'TRADING') + .map( + (Symbol symbol) => + '${symbol.baseAsset}-${symbol.quoteAsset}'.toLowerCase(), + ) + .toList(); + _symbols.sort(); + return _symbols; + } + + /// Fetches the legacy OHLC (Open-High-Low-Close) candle data for a given symbol. + /// The candle data is fetched for the specified durations. + /// If no durations are provided, it fetches the data for all default durations. + /// Returns a map of durations to the corresponding candle data. + /// + /// Parameters: + /// - symbol: The symbol for which to fetch the candle data. + /// - ohlcDurations: The durations for which to fetch the candle data. If not provided, it fetches the data for all default durations. + /// - limit: The maximum number of candles to fetch for each duration. The default is 500, and the maximum is 1000. + /// + /// Returns: + /// A map of durations to the corresponding candle data. + /// + /// Example usage: + /// ```dart + /// final Map candleData = await getLegacyOhlcCandleData('btc-usdt', ohlcDurations: ['1m', '5m', '1h']); + /// ``` + Future> getLegacyOhlcCandleData( + String symbol, { + List ohlcDurations, + int limit = 500, + }) async { + final Map ohlcData = { + ...defaultCandleIntervalsBinanceMap + }; + + // The Binance API requires the symbol to be in uppercase and without any + // special characters, so we remove them here. + symbol = normaliseSymbol(symbol); + ohlcDurations ??= defaultCandleIntervalsBinanceMap.keys.toList(); + + await Future.wait( + ohlcDurations.map( + (String duration) async { + final BinanceKlinesResponse klinesResponse = + await _binanceProvider.fetchKlines( + symbol, + defaultCandleIntervalsBinanceMap[duration], + limit: limit, + ); + + if (klinesResponse != null) { + // Sort the klines in descending order of close time. + // This is necessary for the Candlestick chart to display the data correctly. + klinesResponse.klines.sort( + (BinanceKline a, BinanceKline b) => + b.closeTime.compareTo(a.closeTime), + ); + + ohlcData[duration] = klinesResponse.klines + .map((BinanceKline kline) => kline.toMap()) + .toList(); + } else { + ohlcData[duration] = >[]; + } + }, + ), + ); + + ohlcData['604800_Monday'] = ohlcData['604800']; + + return ohlcData; + } + + /// Normalizes the given [symbol] by removing special characters and converting it to uppercase. + /// + /// The Binance API requires the symbol to be in uppercase and without any special characters. + /// This method removes any dashes or slashes from the symbol and converts it to uppercase. + /// Returns the normalized symbol. + String normaliseSymbol(String symbol) { + return symbol.replaceAll(RegExp(r'[-/]'), '').toUpperCase(); + } +} diff --git a/lib/packages/binance_candlestick_charts/models/binance_exchange_info.dart b/lib/packages/binance_candlestick_charts/models/binance_exchange_info.dart new file mode 100644 index 000000000..529000a5b --- /dev/null +++ b/lib/packages/binance_candlestick_charts/models/binance_exchange_info.dart @@ -0,0 +1,278 @@ +/// Represents the response from the Binance Exchange Info API. +class BinanceExchangeInfoResponse { + BinanceExchangeInfoResponse({ + this.timezone, + this.serverTime, + this.rateLimits, + this.symbols, + }); + + /// Creates a new instance of [BinanceExchangeInfoResponse] from a JSON map. + factory BinanceExchangeInfoResponse.fromJson(Map json) { + return BinanceExchangeInfoResponse( + timezone: json['timezone'], + serverTime: json['serverTime'], + rateLimits: (json['rateLimits'] as List) + .map((dynamic v) => RateLimit.fromJson(v)) + .toList(), + symbols: (json['symbols'] as List) + .map((dynamic v) => Symbol.fromJson(v)) + .toList(), + ); + } + + /// The timezone of the server. Defaults to 'UTC'. + String timezone; + + /// The server time in Unix time (milliseconds). + int serverTime; + + /// The rate limit types for the API endpoints. + List rateLimits; + + /// The list of symbols available on the exchange. + List symbols; +} + +/// Represents a rate limit type for an API endpoint. +class RateLimit { + RateLimit({ + this.rateLimitType, + this.interval, + this.intervalNum, + this.limit, + }); + + /// Creates a new instance of [RateLimit] from a JSON map. + RateLimit.fromJson(Map json) { + rateLimitType = json['rateLimitType']; + interval = json['interval']; + intervalNum = json['intervalNum']; + limit = json['limit']; + } + + /// The type of rate limit. + String rateLimitType; + + /// The interval of the rate limit. + String interval; + + /// The number of intervals. + int intervalNum; + + /// The limit for the rate limit. + int limit; +} + +/// Represents a symbol on the exchange. +class Symbol { + Symbol({ + this.symbol, + this.status, + this.baseAsset, + this.baseAssetPrecision, + this.quoteAsset, + this.quotePrecision, + this.quoteAssetPrecision, + this.baseCommissionPrecision, + this.quoteCommissionPrecision, + this.orderTypes, + this.icebergAllowed, + this.ocoAllowed, + this.quoteOrderQtyMarketAllowed, + this.allowTrailingStop, + this.cancelReplaceAllowed, + this.isSpotTradingAllowed, + this.isMarginTradingAllowed, + this.filters, + this.permissions, + this.defaultSelfTradePreventionMode, + this.allowedSelfTradePreventionModes, + }); + + /// Creates a new instance of [Symbol] from a JSON map. + factory Symbol.fromJson(Map json) { + return Symbol( + symbol: json['symbol'], + status: json['status'], + baseAsset: json['baseAsset'], + baseAssetPrecision: json['baseAssetPrecision'], + quoteAsset: json['quoteAsset'], + quotePrecision: json['quotePrecision'], + quoteAssetPrecision: json['quoteAssetPrecision'], + baseCommissionPrecision: json['baseCommissionPrecision'], + quoteCommissionPrecision: json['quoteCommissionPrecision'], + orderTypes: (json['orderTypes'] as List) + .map((dynamic orderType) => orderType.toString()) + .toList(), + icebergAllowed: json['icebergAllowed'], + ocoAllowed: json['ocoAllowed'], + quoteOrderQtyMarketAllowed: json['quoteOrderQtyMarketAllowed'], + allowTrailingStop: json['allowTrailingStop'], + cancelReplaceAllowed: json['cancelReplaceAllowed'], + isSpotTradingAllowed: json['isSpotTradingAllowed'], + isMarginTradingAllowed: json['isMarginTradingAllowed'], + permissions: (json['permissions'] as List) + .map((dynamic permission) => permission.toString()) + .toList(), + defaultSelfTradePreventionMode: json['defaultSelfTradePreventionMode'], + allowedSelfTradePreventionModes: + (json['allowedSelfTradePreventionModes'] as List) + .map((dynamic mode) => mode.toString()) + .toList(), + filters: (json['filters'] as List) + .map((dynamic v) => Filter.fromJson(v)) + .toList(), + ); + } + + /// The symbol name. + String symbol; + + /// The status of the symbol. + String status; + + /// The base asset of the symbol. + String baseAsset; + + /// The precision of the base asset. + int baseAssetPrecision; + + /// The quote asset of the symbol. + String quoteAsset; + + /// The precision of the quote asset. + int quotePrecision; + + /// The precision of the quote asset for commission calculations. + int quoteAssetPrecision; + + /// The precision of the base asset for commission calculations. + int baseCommissionPrecision; + + /// The precision of the quote asset for commission calculations. + int quoteCommissionPrecision; + + /// The types of orders supported for the symbol. + List orderTypes; + + /// Whether iceberg orders are allowed for the symbol. + bool icebergAllowed; + + /// Whether OCO (One-Cancels-the-Other) orders are allowed for the symbol. + bool ocoAllowed; + + /// Whether quote order quantity market orders are allowed for the symbol. + bool quoteOrderQtyMarketAllowed; + + /// Whether trailing stop orders are allowed for the symbol. + bool allowTrailingStop; + + /// Whether cancel/replace orders are allowed for the symbol. + bool cancelReplaceAllowed; + + /// Whether spot trading is allowed for the symbol. + bool isSpotTradingAllowed; + + /// Whether margin trading is allowed for the symbol. + bool isMarginTradingAllowed; + + /// The filters applied to the symbol. + List filters; + + /// The permissions required to trade the symbol. + List permissions; + + /// The default self-trade prevention mode for the symbol. + String defaultSelfTradePreventionMode; + + /// The allowed self-trade prevention modes for the symbol. + List allowedSelfTradePreventionModes; +} + +/// Represents a filter applied to a symbol. +class Filter { + Filter({ + this.filterType, + this.minPrice, + this.maxPrice, + this.tickSize, + this.minQty, + this.maxQty, + this.stepSize, + this.limit, + this.minNotional, + this.applyMinToMarket, + this.maxNotional, + this.applyMaxToMarket, + this.avgPriceMins, + this.maxNumOrders, + this.maxNumAlgoOrders, + }); + + /// Creates a new instance of [Filter] from a JSON map. + factory Filter.fromJson(Map json) { + return Filter( + filterType: json['filterType'], + minPrice: json['minPrice'], + maxPrice: json['maxPrice'], + tickSize: json['tickSize'], + minQty: json['minQty'], + maxQty: json['maxQty'], + stepSize: json['stepSize'], + limit: json['limit'], + minNotional: json['minNotional'], + applyMinToMarket: json['applyMinToMarket'], + maxNotional: json['maxNotional'], + applyMaxToMarket: json['applyMaxToMarket'], + avgPriceMins: json['avgPriceMins'], + maxNumOrders: json['maxNumOrders'], + maxNumAlgoOrders: json['maxNumAlgoOrders'], + ); + } + + /// The type of filter. + String filterType; + + /// The minimum price allowed for the symbol. + String minPrice; + + /// The maximum price allowed for the symbol. + String maxPrice; + + /// The tick size for the symbol. + String tickSize; + + /// The minimum quantity allowed for the symbol. + String minQty; + + /// The maximum quantity allowed for the symbol. + String maxQty; + + /// The step size for the symbol. + String stepSize; + + /// The maximum number of orders allowed for the symbol. + int limit; + + /// The minimum notional value allowed for the symbol. + String minNotional; + + /// Whether the minimum notional value applies to market orders. + bool applyMinToMarket; + + /// The maximum notional value allowed for the symbol. + String maxNotional; + + /// Whether the maximum notional value applies to market orders. + bool applyMaxToMarket; + + /// The number of minutes required to calculate the average price. + int avgPriceMins; + + /// The maximum number of orders allowed for the symbol. + int maxNumOrders; + + /// The maximum number of algorithmic orders allowed for the symbol. + int maxNumAlgoOrders; +} diff --git a/lib/packages/binance_candlestick_charts/models/binance_klines.dart b/lib/packages/binance_candlestick_charts/models/binance_klines.dart new file mode 100644 index 000000000..813bb647c --- /dev/null +++ b/lib/packages/binance_candlestick_charts/models/binance_klines.dart @@ -0,0 +1,119 @@ +/// Represents the response from the Binance API for klines/candlestick data. +class BinanceKlinesResponse { + /// Creates a new instance of [BinanceKlinesResponse]. + BinanceKlinesResponse({this.klines}); + + /// Creates a new instance of [BinanceKlinesResponse] from a JSON array. + factory BinanceKlinesResponse.fromJson(List json) { + return BinanceKlinesResponse( + klines: + json.map((dynamic kline) => BinanceKline.fromJson(kline)).toList(), + ); + } + + /// The list of klines (candlestick data). + final List klines; + + /// Converts the [BinanceKlinesResponse] object to a JSON array. + List toJson() { + return klines.map((BinanceKline kline) => kline.toJson()).toList(); + } +} + +/// Represents a Binance Kline (candlestick) data. +class BinanceKline { + /// Creates a new instance of [BinanceKline]. + BinanceKline({ + this.openTime, + this.open, + this.high, + this.low, + this.close, + this.volume, + this.closeTime, + this.quoteAssetVolume, + this.numberOfTrades, + this.takerBuyBaseAssetVolume, + this.takerBuyQuoteAssetVolume, + }); + + /// Creates a new instance of [BinanceKline] from a JSON array. + factory BinanceKline.fromJson(List json) { + return BinanceKline( + openTime: json[0], + open: double.parse(json[1]), + high: double.parse(json[2]), + low: double.parse(json[3]), + close: double.parse(json[4]), + volume: double.parse(json[5]), + closeTime: json[6], + quoteAssetVolume: double.parse(json[7]), + numberOfTrades: json[8], + takerBuyBaseAssetVolume: double.parse(json[9]), + takerBuyQuoteAssetVolume: double.parse(json[10]), + ); + } + + /// Converts the [BinanceKline] object to a JSON array. + List toJson() { + return [ + openTime, + open, + high, + low, + close, + volume, + closeTime, + quoteAssetVolume, + numberOfTrades, + takerBuyBaseAssetVolume, + takerBuyQuoteAssetVolume, + ]; + } + + /// Converts the kline data into a JSON object like that returned in the previously used OHLC endpoint. + Map toMap() { + return { + 'timestamp': openTime, + 'open': open, + 'high': high, + 'low': low, + 'close': close, + 'volume': volume, + 'quote_volume': quoteAssetVolume + }; + } + + /// The opening time of the kline as a Unix timestamp since epoch (UTC). + final int openTime; + + /// The opening price of the kline. + final double open; + + /// The highest price reached during the kline. + final double high; + + /// The lowest price reached during the kline. + final double low; + + /// The closing price of the kline. + final double close; + + /// The trading volume during the kline. + final double volume; + + /// The closing time of the kline. + final int closeTime; + + /// The quote asset volume during the kline. + final double quoteAssetVolume; + + /// The number of trades executed during the kline. + final int numberOfTrades; + + /// The volume of the asset bought by takers during the kline. + final double takerBuyBaseAssetVolume; + + /// The quote asset volume of the asset bought by takers during the kline. + final double takerBuyQuoteAssetVolume; +} diff --git a/lib/packages/binance_candlestick_charts/test/binance_provider_test.dart b/lib/packages/binance_candlestick_charts/test/binance_provider_test.dart new file mode 100644 index 000000000..e7f8d1f63 --- /dev/null +++ b/lib/packages/binance_candlestick_charts/test/binance_provider_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../bloc/binance_provider.dart'; +import '../models/binance_exchange_info.dart'; +import '../models/binance_klines.dart'; + +void main() { + group('BinanceProvider', () { + const apiUrl = 'https://api.binance.com/api/v3'; + + test('fetchKlines returns BinanceKlinesResponse when successful', () async { + final BinanceProvider provider = BinanceProvider(apiUrl: apiUrl); + const String symbol = 'BTCUSDT'; + const String interval = '1m'; + const int limit = 100; + + final BinanceKlinesResponse result = + await provider.fetchKlines(symbol, interval, limit: limit); + + expect(result, isA()); + expect(result.klines.isNotEmpty, true); + }); + + test('fetchKlines throws an exception when unsuccessful', () async { + final BinanceProvider provider = BinanceProvider(apiUrl: apiUrl); + const String symbol = 'invalid_symbol'; + const String interval = '1m'; + const int limit = 100; + + expect( + () => provider.fetchKlines(symbol, interval, limit: limit), + throwsException, + ); + }); + + test('fetchExchangeInfo returns a valid object when successful', () async { + final BinanceProvider provider = BinanceProvider(apiUrl: apiUrl); + + final BinanceExchangeInfoResponse result = + await provider.fetchExchangeInfo(); + + expect(result, isA()); + expect(result.timezone, isA()); + expect(result.serverTime, isA()); + expect(result.rateLimits, isA>()); + expect(result.rateLimits.isNotEmpty, true); + expect(result.symbols, isA>()); + expect(result.symbols.isNotEmpty, true); + }); + }); +} diff --git a/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart b/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart new file mode 100644 index 000000000..c49742fdc --- /dev/null +++ b/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../bloc/binance_repository.dart'; + +void main() { + group('BinanceRepository', () { + final BinanceRepository repository = BinanceRepository(); + + test('getLegacyOhlcCandleData returns a valid map when successful', + () async { + const String symbol = 'eth-btc'; + + // Call the method + final Map result = + await repository.getLegacyOhlcCandleData(symbol); + + final Map resultMin = + (result['60'] as List>).first; + + // Perform assertions + expect(result, isA>()); + expect(resultMin.containsKey('open'), true); + expect(resultMin.containsKey('high'), true); + expect(resultMin.containsKey('low'), true); + expect(resultMin.containsKey('close'), true); + expect(resultMin.containsKey('volume'), true); + }); + + test('getLegacyOhlcCandleData throws an exception when unsuccessful', + () async { + // Prepare test data + const String symbol = 'invalid_symbol/'; + + // Call the method and expect an exception to be thrown + expect( + () => repository.getLegacyOhlcCandleData(symbol), + throwsException, + ); + }); + + test('normaliseSymbol returns the correct symbol when given a valid symbol', + () { + // Prepare test data + const String symbol = 'eth-btc'; + + // Call the method + final String result = repository.normaliseSymbol(symbol); + + // Perform assertions + expect(result, 'ETHBTC'); + }); + + test( + 'normaliseSymbol returns a normalised version of the input when given an invalid symbol', + () { + // Prepare test data + const String symbol = 'invalid_symbol/'; + + final String result = repository.normaliseSymbol(symbol); + + // Call the method and expect an exception to be thrown + expect(result, 'INVALID_SYMBOL'); + }); + }); +} diff --git a/lib/screens/markets/build_coin_price_list_item.dart b/lib/screens/markets/build_coin_price_list_item.dart index e496f4bab..c72c7eaa0 100644 --- a/lib/screens/markets/build_coin_price_list_item.dart +++ b/lib/screens/markets/build_coin_price_list_item.dart @@ -1,17 +1,17 @@ import 'package:flutter/material.dart'; -import '../../localizations.dart'; +import 'package:provider/provider.dart'; +import '../../app_config/theme_data.dart'; +import '../../localizations.dart'; import '../../model/balance.dart'; import '../../model/cex_provider.dart'; import '../../model/coin.dart'; import '../../model/coin_balance.dart'; -import '../markets/candlestick_chart.dart'; import '../../utils/utils.dart'; import '../../widgets/candles_icon.dart'; import '../../widgets/cex_data_marker.dart'; import '../../widgets/duration_select.dart'; -import '../../app_config/theme_data.dart'; -import 'package:provider/provider.dart'; +import '../markets/candlestick_chart.dart'; class BuildCoinPriceListItem extends StatefulWidget { const BuildCoinPriceListItem({this.coinBalance, this.onTap, Key key}) @@ -28,24 +28,22 @@ class _BuildCoinPriceListItemState extends State { Coin coin; Balance balance; bool expanded = false; - bool fetching = false; // todo(yurii): will get flag from CexProvider bool quotedChart = false; String chartDuration = '3600'; CexProvider cexProvider; - String _currency; @override Widget build(BuildContext context) { - cexProvider = Provider.of(context); - final bool _hasNonzeroPrice = - double.parse(widget.coinBalance.priceForOne ?? '0') > 0; + cexProvider = Provider.of(context, listen: false); coin = widget.coinBalance.coin; balance = widget.coinBalance.balance; - _currency = cexProvider.currency.toLowerCase() == 'usd' + final bool coinHasNonZeroPrice = + double.parse(widget.coinBalance.priceForOne ?? '0') > 0; + final String _currency = cexProvider.currency.toLowerCase() == 'usd' ? 'USDC' : cexProvider.currency.toUpperCase(); - final bool _hasChartData = cexProvider + final bool coinHasChartData = cexProvider .isChartAvailable('${widget.coinBalance.coin.abbr}-$_currency'); return Column( @@ -63,35 +61,38 @@ class _BuildCoinPriceListItemState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Card( - margin: EdgeInsets.all(0), + margin: const EdgeInsets.all(0), child: Row( children: [ Expanded( - child: InkWell( - onTap: widget.onTap, - child: Container( - height: 64, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - CircleAvatar( - radius: 18, - backgroundColor: Colors.transparent, - backgroundImage: - AssetImage(getCoinIconPath(balance.coin)), - ), - const SizedBox(width: 8), - Text( - coin.name.toUpperCase(), - style: Theme.of(context) - .textTheme - .subtitle2 - .copyWith(fontSize: 14), - ) - ], + child: InkWell( + onTap: widget.onTap, + child: Container( + height: 64, + padding: + const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: Colors.transparent, + backgroundImage: AssetImage( + getCoinIconPath(balance.coin), + ), + ), + const SizedBox(width: 8), + Text( + coin.name.toUpperCase(), + style: Theme.of(context) + .textTheme + .subtitle2 + .copyWith(fontSize: 14), + ) + ], + ), ), ), - )), + ), InkWell( onTap: () { setState(() { @@ -103,7 +104,7 @@ class _BuildCoinPriceListItemState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ - if (_hasNonzeroPrice) + if (coinHasNonZeroPrice) Row( children: [ CexMarker( @@ -114,25 +115,28 @@ class _BuildCoinPriceListItemState extends State { width: 4, ), Text( - cexProvider.convert(double.parse( - widget.coinBalance.priceForOne, - )), + cexProvider.convert( + double.parse( + widget.coinBalance.priceForOne, + ), + ), style: Theme.of(context) .textTheme .subtitle2 .copyWith( - color: Theme.of(context) - .brightness == - Brightness.light - ? cexColorLight - : cexColor, - fontSize: 14, - fontWeight: FontWeight.normal), + color: Theme.of(context) + .brightness == + Brightness.light + ? cexColorLight + : cexColor, + fontSize: 14, + fontWeight: FontWeight.normal, + ), ), ], ), Container( - child: _hasNonzeroPrice && _hasChartData + child: coinHasNonZeroPrice && coinHasChartData ? CandlesIcon( size: 14, color: Theme.of(context).brightness == @@ -154,12 +158,51 @@ class _BuildCoinPriceListItemState extends State { ), ], ), - if (expanded && _hasChartData) _buildChart(), + if (expanded && coinHasChartData) + CoinPriceChart( + coin: widget.coinBalance.coin, + currency: _currency, + cexProvider: cexProvider, + chartDuration: chartDuration, + quotedChart: quotedChart, + onDurationChange: (String duration) { + setState(() { + chartDuration = duration; + }); + }, + onQuotedChange: (bool quoted) { + setState(() { + quotedChart = quoted; + }); + }, + ), ], ); } +} + +class CoinPriceChart extends StatelessWidget { + const CoinPriceChart({ + @required this.coin, + @required this.currency, + @required this.cexProvider, + @required this.chartDuration, + @required this.quotedChart, + @required this.onDurationChange, + @required this.onQuotedChange, + Key key, + }) : super(key: key); - Widget _buildChart() { + final Coin coin; + final String currency; + final CexProvider cexProvider; + final String chartDuration; + final bool quotedChart; + final void Function(String) onDurationChange; + final void Function(bool) onQuotedChange; + + @override + Widget build(BuildContext context) { const double controlsBarHeight = 60; final double chartHeight = MediaQuery.of(context).size.height / 2; @@ -179,7 +222,7 @@ class _BuildCoinPriceListItemState extends State { Expanded( child: FutureBuilder( future: cexProvider.getCandles( - '${widget.coinBalance.coin.abbr}-$_currency', + '${coin.abbr}-$currency', double.parse(chartDuration), ), builder: @@ -187,23 +230,25 @@ class _BuildCoinPriceListItemState extends State { List candles; if (snapshot.hasData) { if (snapshot.data.data[chartDuration].isEmpty) { - List all = snapshot.data.data.keys.toList(); - int nextDuration = all.indexOf(chartDuration) + 1; - if (all.length > nextDuration) - chartDuration = all[nextDuration]; - return SizedBox(); + final List all = snapshot.data.data.keys.toList(); + final int nextDuration = all.indexOf(chartDuration) + 1; + if (all.length > nextDuration) { + onDurationChange(all[nextDuration]); + } + return const SizedBox(); } candles = snapshot.data.data[chartDuration]; if (candles == null) { - chartDuration = snapshot.data.data.keys.first; + onDurationChange(snapshot.data.data.keys.first); candles = snapshot.data.data[chartDuration]; } } List _buildDisclaimer() { - if (!snapshot.hasData || snapshot.data.chain.length < 2) + if (!snapshot.hasData || snapshot.data.chain.length < 2) { return []; + } final String mediateBase = snapshot.data.chain[0].reverse ? snapshot.data.chain[0].rel.toUpperCase() @@ -212,20 +257,23 @@ class _BuildCoinPriceListItemState extends State { return [ const SizedBox(width: 4), Text( - '(${AppLocalizations.of(context).basedOnCoinRatio(widget.coinBalance.coin.abbr, mediateBase)})', + '(${AppLocalizations.of(context).basedOnCoinRatio(coin.abbr, mediateBase)})', style: TextStyle( - fontSize: 12, - color: - Theme.of(context).brightness == Brightness.light - ? cexColorLight - : cexColor), + fontSize: 12, + color: Theme.of(context).brightness == Brightness.light + ? cexColorLight + : cexColor, + ), ) ]; } - List options = []; - snapshot.data?.data?.forEach((key, value) { - if (value.isNotEmpty) options.add(key); + final List options = []; + snapshot.data?.data + ?.forEach((String key, List value) { + if (value.isNotEmpty) { + options.add(key); + } }); return Column( children: [ @@ -241,58 +289,54 @@ class _BuildCoinPriceListItemState extends State { value: chartDuration, options: options, disabled: !snapshot.hasData, - onChange: (String value) { - setState(() { - chartDuration = value; - }); - }, + onChange: onDurationChange, ), ..._buildDisclaimer(), - Expanded(child: SizedBox()), + const Expanded(child: SizedBox()), ElevatedButton( - onPressed: snapshot.hasData - ? () { - setState(() { - quotedChart = !quotedChart; - }); - } - : null, - style: elevatedButtonSmallButtonStyle(), - child: Text( - quotedChart - ? '$_currency/${widget.coinBalance.coin.abbr}' - : '${widget.coinBalance.coin.abbr}/$_currency', - style: const TextStyle(fontSize: 12), - )), + onPressed: snapshot.hasData + ? () => onQuotedChange(!quotedChart) + : null, + style: elevatedButtonSmallButtonStyle(), + child: Text( + quotedChart + ? '$currency/${coin.abbr}' + : '${coin.abbr}/$currency', + style: const TextStyle(fontSize: 12), + ), + ), ], ), ), Container( - height: chartHeight, - clipBehavior: Clip.hardEdge, - // `decoration` required if `clipBehavior != null` - decoration: BoxDecoration(), - child: snapshot.hasData - ? CandleChart( - data: candles, - duration: int.parse(chartDuration), - quoted: quotedChart, - textColor: Theme.of(context).brightness == - Brightness.light - ? Colors.black - : Colors.white, - gridColor: Theme.of(context).brightness == - Brightness.light - ? Colors.black.withOpacity(.2) - : Colors.white.withOpacity(.4)) - : snapshot.hasError - ? Center( - child: Text(AppLocalizations.of(context) - .candleChartError), - ) - : const Center( - child: CircularProgressIndicator(), - )), + height: chartHeight, + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: snapshot.hasData + ? CandleChart( + data: candles, + duration: int.parse(chartDuration), + quoted: quotedChart, + textColor: Theme.of(context).brightness == + Brightness.light + ? Colors.black + : Colors.white, + gridColor: Theme.of(context).brightness == + Brightness.light + ? Colors.black.withOpacity(.2) + : Colors.white.withOpacity(.4), + ) + : snapshot.hasError + ? Center( + child: Text( + AppLocalizations.of(context) + .candleChartError, + ), + ) + : const Center( + child: CircularProgressIndicator(), + ), + ), ], ); }, diff --git a/lib/screens/markets/candlestick_chart.dart b/lib/screens/markets/candlestick_chart.dart index 7e560da5d..da41a468a 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -30,6 +30,19 @@ class CandleChart extends StatefulWidget { final bool quoted; final Color gridColor; + // Constants previously declared in the paint method + // Moved these here to make them accessible and possibly configurable + int get scrollDragFactor => 5; + double get pricePaddingPercent => 15; + double get pricePreferredDivisions => 4; + double get gap => 2; + double get marginTop => 14; + double get marginBottom => 30; + double get labelWidth => 100; + int get visibleCandlesLimit => 500; + int get adjustedVisibleCandleLimit => + visibleCandlesLimit > data.length ? data.length : visibleCandlesLimit; + @override CandleChartState createState() => CandleChartState(); } @@ -45,7 +58,7 @@ class CandleChartState extends State double maxTimeShift; Size canvasSize; Offset tapPosition; - Map selectedPoint; // {'timestamp': int, 'price': double} + Map selectedPoint; @override void initState() { @@ -60,40 +73,16 @@ class CandleChartState extends State if (oldWidget.quoted != widget.quoted) { selectedPoint = null; } + if (oldWidget.duration != widget.duration) { + timeAxisShift = 0; + staticZoom = 1; + dynamicZoom = 1; + } super.didUpdateWidget(oldWidget); } @override Widget build(BuildContext context) { - double _constrainedTimeShift(double timeShift) { - const overScroll = 70; - - if (timeShift * staticZoom * dynamicZoom < -overScroll) - return -overScroll / staticZoom / dynamicZoom; - - if (maxTimeShift == null) return timeShift; - - if (timeShift * staticZoom * dynamicZoom > maxTimeShift + overScroll) - return (maxTimeShift + overScroll) / staticZoom / dynamicZoom; - - return timeShift; - } - - double _constrainedZoom(double scale) { - double constrained = scale; - - final double maxZoom = canvasSize.width / 5 / widget.candleWidth; - if (staticZoom * scale > maxZoom) { - constrained = maxZoom / staticZoom; - } - final double minZoom = canvasSize.width / 500 / widget.candleWidth; - if (staticZoom * scale < minZoom) { - constrained = minZoom / staticZoom; - } - - return constrained; - } - return SizedBox( child: Listener( onPointerDown: (_) { @@ -107,45 +96,12 @@ class CandleChartState extends State }); }, child: GestureDetector( - onHorizontalDragUpdate: (DragUpdateDetails drag) { - if (touchCounter > 1) return; - - setState(() { - timeAxisShift = _constrainedTimeShift( - timeAxisShift + drag.delta.dx / staticZoom / dynamicZoom); - }); - }, - onScaleStart: (_) { - setState(() { - prevTimeAxisShift = timeAxisShift; - }); - }, - onScaleEnd: (_) { - setState(() { - staticZoom = staticZoom * dynamicZoom; - dynamicZoom = 1; - }); - }, - onScaleUpdate: (ScaleUpdateDetails scale) { - setState(() { - dynamicZoom = _constrainedZoom(scale.scale); - timeAxisShift = _constrainedTimeShift(prevTimeAxisShift - - canvasSize.width / - 2 * - (1 - dynamicZoom) / - (staticZoom * dynamicZoom)); - }); - }, - onTapDown: (TapDownDetails details) { - tapPosition = null; - - setState(() { - tapDownPosition = details.localPosition; - }); - }, - onTap: () { - tapPosition = tapDownPosition; - }, + onHorizontalDragUpdate: _onDragUpdate, + onScaleStart: _onScaleStart, + onScaleEnd: _onScaleEnd, + onScaleUpdate: _onScaleUpdate, + onTapDown: _onTapDown, + onTap: _onTap, child: CustomPaint( painter: _ChartPainter( widget: widget, @@ -153,34 +109,7 @@ class CandleChartState extends State zoom: staticZoom * dynamicZoom, tapPosition: tapPosition, selectedPoint: selectedPoint, - setWidgetState: (String prop, dynamic value) { - WidgetsBinding.instance.addPostFrameCallback((_) { - setState(() { - switch (prop) { - case 'maxTimeShift': - { - maxTimeShift = value; - break; - } - case 'canvasSize': - { - canvasSize = value; - break; - } - case 'tapPosition': - { - tapPosition = value; - break; - } - case 'selectedPoint': - { - selectedPoint = value; - break; - } - } - }); - }); - }, + setWidgetState: _painterStateCallback, ), child: Center( child: Container(), @@ -190,6 +119,106 @@ class CandleChartState extends State ), ); } + + void _onTap() { + tapPosition = tapDownPosition; + } + + void _onScaleStart(_) { + setState(() { + prevTimeAxisShift = timeAxisShift; + }); + } + + void _onScaleEnd(_) { + setState(() { + staticZoom = staticZoom * dynamicZoom; + dynamicZoom = 1; + }); + } + + void _onTapDown(TapDownDetails details) { + tapPosition = null; + + setState(() { + tapDownPosition = details.localPosition; + }); + } + + void _onDragUpdate(DragUpdateDetails drag) { + if (touchCounter > 1) { + return; + } + + setState(() { + final double adjustedDragDelta = + drag.delta.dx / widget.scrollDragFactor / staticZoom / dynamicZoom; + timeAxisShift = _constrainedTimeShift( + timeAxisShift + adjustedDragDelta, + ); + }); + } + + void _onScaleUpdate(ScaleUpdateDetails details) { + final double constrainedZoom = _constrainedZoom(details.scale); + if (constrainedZoom == 1) { + return; + } + + setState(() { + dynamicZoom = constrainedZoom; + }); + } + + void _painterStateCallback(String prop, dynamic value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + switch (prop) { + case 'maxTimeShift': + { + maxTimeShift = value; + break; + } + case 'canvasSize': + { + canvasSize = value; + break; + } + case 'tapPosition': + { + tapPosition = value; + break; + } + case 'selectedPoint': + { + selectedPoint = value; + break; + } + } + }); + }); + } + + double _constrainedTimeShift(double timeShift) { + final double maxTimeShiftValue = maxTimeShift ?? timeShift; + return timeShift.clamp(0.0, maxTimeShiftValue); + } + + double _constrainedZoom(double scale) { + double constrained = scale; + + final double maxZoom = canvasSize.width / 5 / widget.candleWidth; + if (staticZoom * scale > maxZoom) { + constrained = maxZoom / staticZoom; + } + final double minZoom = + canvasSize.width / widget.visibleCandlesLimit / widget.candleWidth; + if (staticZoom * scale < minZoom) { + constrained = minZoom / staticZoom; + } + + return constrained; + } } class _ChartPainter extends CustomPainter { @@ -200,291 +229,441 @@ class _ChartPainter extends CustomPainter { this.tapPosition, this.selectedPoint, this.setWidgetState, - }); + }) { + painter = _initializePaint(); + } final CandleChart widget; final double timeAxisShift; final double zoom; final Offset tapPosition; final Map selectedPoint; - final Function(String, dynamic) setWidgetState; + final void Function(String, dynamic) setWidgetState; + + Paint painter; @override void paint(Canvas canvas, Size size) { setWidgetState('canvasSize', size); - final List data = _sortByTime(widget.data); - final double candleWidth = widget.candleWidth; - const double pricePaddingPercent = 15; - const double pricePreferredDivisions = 4; - const double gap = 2; - const double marginTop = 14; - const double marginBottom = 30; - const double labelWidth = 100; - final double fieldHeight = size.height - marginBottom - marginTop; - - // adjust time asix - double visibleCandlesNumber = size.width / (candleWidth + gap) / zoom; - if (visibleCandlesNumber < 1) visibleCandlesNumber = 1; - final double timeRange = visibleCandlesNumber * widget.duration; - final double timeScaleFactor = size.width / timeRange; + final double fieldHeight = + size.height - widget.marginBottom - widget.marginTop; + final int maxVisibleCandles = + (size.width / (widget.candleWidth + widget.gap) / zoom) + .floor() + .clamp(0, widget.adjustedVisibleCandleLimit); + setWidgetState( - 'maxTimeShift', - (data.first.closeTime - data.last.closeTime) * timeScaleFactor - - timeRange * timeScaleFactor); - final double timeAxisMax = - data[0].closeTime - timeAxisShift * zoom / timeScaleFactor; + 'maxTimeShift', + (widget.data.length - maxVisibleCandles).toDouble(), + ); + + final VisibleCandles visibleCandles = _collectVisibleCandles( + maxVisibleCandles, + size, + fieldHeight, + ); + final CandleGridData gridData = visibleCandles.gridData; + + /// Converts a price to a y-coordinate for the candlestick chart. + /// Keeping this function here to avoid the need to pass the widget, size + /// and gridData to each call of the function. + double _price2dy(double price) { + final double relativePrice = price - gridData.originPrice; + final double scaledPrice = relativePrice * gridData.priceScaleFactor; + return size.height - widget.marginBottom - scaledPrice; + } + + /// Converts a time to an x-coordinate for the candlestick chart. + double _time2dx(int time) { + final double relativeTime = time.toDouble() - gridData.timeAxisMin; + final double scaledTime = relativeTime * gridData.timeScaleFactor; + final double adjustment = (widget.candleWidth + widget.gap) * zoom / 2; + return scaledTime - adjustment; + } + + _drawCandles(visibleCandles.candles, _time2dx, _price2dy, canvas); + _drawPriceGrid( + size, + gridData, + _price2dy, + canvas, + ); + _drawCurrentPrice( + visibleCandles.candles, _price2dy, size, fieldHeight, canvas); + _drawTimeGrid(size, canvas, visibleCandles.candles, _time2dx); + + if (tapPosition != null) { + _calculateSelectedPoint(visibleCandles.candles, _time2dx, _price2dy); + } + + if (selectedPoint != null) { + _drawSelectedPoint( + visibleCandles.candles, _time2dx, _price2dy, canvas, size); + } + } + + VisibleCandles _collectVisibleCandles( + int maxVisibleCandles, + Size size, + double fieldHeight, + ) { + final int firstCandleIndex = + timeAxisShift.floor().clamp(0, widget.data.length - maxVisibleCandles); + final int lastVisibleCandleIndex = (firstCandleIndex + maxVisibleCandles) + .clamp(maxVisibleCandles - 1, widget.data.length - 1); + final int firstCandleCloseTime = widget.data[firstCandleIndex].closeTime; + final int lastCandleCloseTime = + widget.data[lastVisibleCandleIndex].closeTime; + final int timeRange = firstCandleCloseTime - lastCandleCloseTime; + final double timeScaleFactor = size.width / timeRange; + final double timeAxisMax = firstCandleCloseTime - zoom / timeScaleFactor; final double timeAxisMin = timeAxisMax - timeRange; - //collect visible candles data - final List visibleCandlesData = []; - for (int i = 0; i < data.length; i++) { - final CandleData candle = data[i]; + final List visibleCandlesData = []; + double minPrice = double.infinity; + double maxPrice = 0; + for (int i = firstCandleIndex; i < lastVisibleCandleIndex; i++) { + final CandleData candle = widget.data[i]; final double dx = (candle.closeTime - timeAxisMin) * timeScaleFactor; - if (dx > size.width + candleWidth * zoom) continue; - if (dx < 0) break; + if (dx > size.width + widget.candleWidth * zoom) { + continue; + } + if (dx.isNegative) { + break; + } + + final double lowPrice = _price(candle.lowPrice); + final double highPrice = _price(candle.highPrice); + if (lowPrice < minPrice) { + minPrice = lowPrice; + } + if (highPrice > maxPrice) { + maxPrice = highPrice; + } + visibleCandlesData.add(candle); } - // adjust price axis - final double minPrice = _minPrice(visibleCandlesData); - final double maxPrice = _maxPrice(visibleCandlesData); + return VisibleCandles( + candles: visibleCandlesData, + gridData: _calculateGridScale( + minPrice, + maxPrice, + fieldHeight, + timeAxisMax, + timeAxisMin, + timeScaleFactor, + ), + ); + } + + CandleGridData _calculateGridScale( + double minPrice, + double maxPrice, + double fieldHeight, + double timeAxisMax, + double timeAxisMin, + double timeScaleFactor, + ) { final double priceRange = maxPrice - minPrice; - final double priceAxis = - priceRange + (2 * priceRange * pricePaddingPercent / 100); + + // Calculate the price axis, taking into account the padding + final double padding = 2 * priceRange * widget.pricePaddingPercent / 100; + final double priceAxis = priceRange + padding; final double priceScaleFactor = fieldHeight / priceAxis; final double priceDivision = - _priceDivision(priceAxis, pricePreferredDivisions); - final double originPrice = - ((minPrice - (priceRange * pricePaddingPercent / 100)) / priceDivision) - .round() * - priceDivision; + _priceDivision(priceAxis, widget.pricePreferredDivisions); - // returns dy for given price - double _price2dy(double price) { - return size.height - - marginBottom - - ((price - originPrice) * priceScaleFactor); - } + // Calculate the origin price + final double originPrice = _calculateOriginPrice( + minPrice, + priceRange, + priceDivision, + ); - // returns dx for given time - double _time2dx(int time) { - return (time.toDouble() - timeAxisMin) * timeScaleFactor - - (candleWidth + gap) * zoom / 2; - } + return CandleGridData( + originPrice: originPrice, + priceScaleFactor: priceScaleFactor, + priceDivision: priceDivision, + timeAxisMax: timeAxisMax, + timeAxisMin: timeAxisMin, + timeScaleFactor: timeScaleFactor, + ); + } - final Paint paint = Paint() - ..style = widget.filled ? PaintingStyle.fill : PaintingStyle.stroke - ..strokeWidth = widget.strokeWidth - ..strokeCap = StrokeCap.round; + double _calculateOriginPrice( + double minPrice, double priceRange, double priceDivision) { + final double paddingForMinPrice = + minPrice - (priceRange * widget.pricePaddingPercent / 100); + final int roundedPrice = (paddingForMinPrice / priceDivision).round(); + final double originPrice = roundedPrice * priceDivision; + return originPrice; + } - // calculate candles position - final Map candlesToRender = {}; - for (CandleData candle in visibleCandlesData) { + void _drawCandles( + List visibleCandlesData, + double Function(int time) _time2dx, + double Function(double price) _price2dy, + Canvas canvas, + ) { + for (final CandleData candle in visibleCandlesData) { final double dx = _time2dx(candle.closeTime); + final double closePrice = _price(candle.closePrice); + final double openPrice = _price(candle.openPrice); - final double top = - _price2dy(max(_price(candle.closePrice), _price(candle.openPrice))); - double bottom = - _price2dy(min(_price(candle.closePrice), _price(candle.openPrice))); - if (bottom - top < widget.strokeWidth) bottom = top + widget.strokeWidth; + final double top = _price2dy(max(closePrice, openPrice)); + double bottom = _price2dy(min(closePrice, openPrice)); + if (bottom - top < widget.strokeWidth) { + bottom = top + widget.strokeWidth; + } - candlesToRender[candle.closeTime] = CandlePosition( - color: _price(candle.closePrice) < _price(candle.openPrice) - ? widget.downColor - : widget.upColor, + final CandlePosition candlePosition = CandlePosition( + color: closePrice < openPrice ? widget.downColor : widget.upColor, high: Offset(dx, _price2dy(_price(candle.highPrice))), low: Offset(dx, _price2dy(_price(candle.lowPrice))), - left: dx - candleWidth * zoom / 2, - right: dx + candleWidth * zoom / 2, + left: dx - widget.candleWidth * zoom / 2, + right: dx + widget.candleWidth * zoom / 2, top: top, bottom: bottom, ); + _drawCandle(canvas, painter, candlePosition); } + } - // draw candles - candlesToRender.forEach((int timeStamp, CandlePosition candle) { - _drawCandle(canvas, paint, candle); - }); - - // draw price grid - final int visibleDivisions = - (size.height / (priceDivision * priceScaleFactor)).floor() + 1; + void _drawPriceGrid( + Size size, + CandleGridData gridData, + double Function(double price) _price2dy, + Canvas canvas, + ) { + final double textHeight = + gridData.priceDivision * gridData.priceScaleFactor; + final double divisions = size.height / textHeight; + final int visibleDivisions = divisions.floor() + 1; for (int i = 0; i < visibleDivisions; i++) { - paint.color = widget.gridColor; - final double price = originPrice + i * priceDivision; + painter.color = widget.gridColor; + final double price = gridData.originPrice + i * gridData.priceDivision; final double dy = _price2dy(price); - canvas.drawLine(Offset(0, dy), Offset(size.width, dy), paint); + canvas.drawLine(Offset(0, dy), Offset(size.width, dy), painter); final String formattedPrice = formatPrice(price, 8); - paint.color = widget.textColor; - if (i < 1) continue; + painter.color = widget.textColor; + + // This is to skip the first price label, which is the origin price. + if (i < 1) { + continue; + } _drawText( canvas: canvas, point: Offset(4, dy), text: formattedPrice, - color: widget.textColor, // widget.textColor, + color: widget.textColor, align: TextAlign.start, - width: labelWidth, + width: widget.labelWidth, ); } + } - //draw current price - final double currentPrice = _price(data.first.closePrice); + void _drawCurrentPrice( + List visibleCandlesData, + double Function(double price) _price2dy, + Size size, + double fieldHeight, + Canvas canvas, + ) { + final double currentPrice = _price(visibleCandlesData.first.closePrice); double currentPriceDy = _price2dy(currentPrice); bool outside = false; - if (currentPriceDy > size.height - marginBottom) { + if (currentPriceDy > size.height - widget.marginBottom) { outside = true; - currentPriceDy = size.height - marginBottom; + currentPriceDy = size.height - widget.marginBottom; } - if (currentPriceDy < size.height - marginBottom - fieldHeight) { + if (currentPriceDy < size.height - widget.marginBottom - fieldHeight) { outside = true; - currentPriceDy = size.height - marginBottom - fieldHeight; + currentPriceDy = size.height - widget.marginBottom - fieldHeight; } final Color currentPriceColor = outside ? const Color.fromARGB(120, 200, 200, 150) : const Color.fromARGB(255, 200, 200, 150); - paint.color = currentPriceColor; + painter.color = currentPriceColor; double startX = 0; while (startX < size.width) { - canvas.drawLine(Offset(startX, currentPriceDy), - Offset(startX + 5, currentPriceDy), paint); + canvas.drawLine( + Offset(startX, currentPriceDy), + Offset(startX + 5, currentPriceDy), + painter, + ); startX += 10; } _drawText( canvas: canvas, - point: Offset(size.width - labelWidth - 2, currentPriceDy - 2), + point: Offset(size.width - widget.labelWidth - 2, currentPriceDy - 2), text: ' ${formatPrice(currentPrice, 8)} ', color: Colors.black, backgroundColor: currentPriceColor, align: TextAlign.end, - width: labelWidth, + width: widget.labelWidth, ); + } - // draw time grid + void _drawTimeGrid( + Size size, + Canvas canvas, + List visibleCandlesData, + double Function(int time) _time2dx, + ) { double rightMarkerPosition = size.width; if (timeAxisShift < 0) { rightMarkerPosition = rightMarkerPosition - - (candleWidth / 2 + gap / 2 - timeAxisShift) * zoom; + (widget.candleWidth / 2 + widget.gap / 2 - timeAxisShift) * zoom; } _drawText( canvas: canvas, - color: widget.textColor, //widget.textColor, + color: widget.textColor, point: Offset( - rightMarkerPosition - labelWidth - 4, + rightMarkerPosition - widget.labelWidth - 4, size.height - 7, ), - text: _formatTime(visibleCandlesData.first.closeTime * 1000), + text: _formatTime(visibleCandlesData.first.closeTime), align: TextAlign.end, - width: labelWidth, + width: widget.labelWidth, ); _drawText( canvas: canvas, - color: widget.textColor, //widget.textColor, + color: widget.textColor, point: Offset( 4, size.height - 7, ), - text: _formatTime(visibleCandlesData.last.closeTime * 1000), + text: _formatTime(visibleCandlesData.last.closeTime), align: TextAlign.start, - width: labelWidth, + width: widget.labelWidth, ); - paint.color = widget.gridColor; - for (CandleData candleData in visibleCandlesData) { + painter.color = widget.gridColor; + for (final CandleData candleData in visibleCandlesData) { final double dx = _time2dx(candleData.closeTime); - canvas.drawLine(Offset(dx, size.height - marginBottom), - Offset(dx, size.height - marginBottom + 5), paint); + canvas.drawLine( + Offset(dx, size.height - widget.marginBottom), + Offset(dx, size.height - widget.marginBottom + 5), + painter, + ); } - paint.color = widget.textColor; //widget.textColor; - canvas.drawLine(Offset(0, size.height - marginBottom), - Offset(0, size.height - marginBottom + 5), paint); - canvas.drawLine(Offset(rightMarkerPosition, size.height - marginBottom), - Offset(rightMarkerPosition, size.height - marginBottom + 5), paint); + painter.color = widget.textColor; + canvas.drawLine( + Offset(0, size.height - widget.marginBottom), + Offset(0, size.height - widget.marginBottom + 5), + painter, + ); + canvas.drawLine( + Offset(rightMarkerPosition, size.height - widget.marginBottom), + Offset(rightMarkerPosition, size.height - widget.marginBottom + 5), + painter, + ); + } - // select point on Tap - if (tapPosition != null) { - setWidgetState('selectedPoint', null); - double minDistance; - for (CandleData candle in visibleCandlesData) { - final List prices = [ - _price(candle.openPrice), - _price(candle.closePrice), - _price(candle.highPrice), - _price(candle.lowPrice), - ].toList(); - - for (double price in prices) { - final double distance = sqrt( - pow(tapPosition.dx - _time2dx(candle.closeTime), 2) + - pow(tapPosition.dy - _price2dy(price), 2)); - if (distance > 30) continue; - if (minDistance != null && distance > minDistance) continue; - - minDistance = distance; - setWidgetState('selectedPoint', { - 'timestamp': candle.closeTime, - 'price': price, - }); + void _calculateSelectedPoint( + List visibleCandlesData, + double Function(int time) _time2dx, + double Function(double price) _price2dy, + ) { + setWidgetState('selectedPoint', null); + double minDistance; + for (final CandleData candle in visibleCandlesData) { + final List prices = [ + _price(candle.openPrice), + _price(candle.closePrice), + _price(candle.highPrice), + _price(candle.lowPrice), + ].toList(); + + for (final double price in prices) { + final double distance = sqrt( + pow(tapPosition.dx - _time2dx(candle.closeTime), 2) + + pow(tapPosition.dy - _price2dy(price), 2), + ); + if (distance > 30) { + continue; + } + if (minDistance != null && distance > minDistance) { + continue; } - } - setWidgetState('tapPosition', null); - } - // draw selected point - if (selectedPoint != null) { - CandleData selectedCandle; - try { - selectedCandle = visibleCandlesData.firstWhere((CandleData candle) { - return candle.closeTime == selectedPoint['timestamp']; + minDistance = distance; + setWidgetState('selectedPoint', { + 'timestamp': candle.closeTime, + 'price': price, }); - } catch (_) {} - - if (selectedCandle != null) { - const double radius = 3; - final double dx = _time2dx(selectedCandle.closeTime); - final double dy = _price2dy(selectedPoint['price']); - paint.style = PaintingStyle.stroke; - - canvas.drawCircle(Offset(dx, dy), radius, paint); - - double startX = dx + radius; - while (startX < size.width) { - canvas.drawLine(Offset(startX, dy), Offset(startX + 5, dy), paint); - startX += 10; - } + } + } + setWidgetState('tapPosition', null); + } - _drawText( - canvas: canvas, - align: TextAlign.right, - color: widget.textColor == Colors.black ? Colors.white : Colors.black, - backgroundColor: widget.textColor, - text: ' ${formatPrice(selectedPoint['price'], 8)} ', - point: Offset(size.width - labelWidth - 2, dy - 2), - width: labelWidth, - ); + void _drawSelectedPoint( + List visibleCandlesData, + double Function(int time) _time2dx, + double Function(double price) _price2dy, + Canvas canvas, + Size size, + ) { + CandleData selectedCandle; + try { + selectedCandle = visibleCandlesData.firstWhere((CandleData candle) { + return candle.closeTime == selectedPoint['timestamp']; + }); + } catch (_) {} + + if (selectedCandle != null) { + const double radius = 3; + final double dx = _time2dx(selectedCandle.closeTime); + final double dy = _price2dy(selectedPoint['price']); + painter.style = PaintingStyle.stroke; + + canvas.drawCircle(Offset(dx, dy), radius, painter); + + double startX = dx + radius; + while (startX < size.width) { + canvas.drawLine(Offset(startX, dy), Offset(startX + 5, dy), painter); + startX += 10; + } - double startY = dy + radius; - while (startY < size.height - marginBottom + 10) { - canvas.drawLine(Offset(dx, startY), Offset(dx, startY + 5), paint); - startY += 10; - } + _drawText( + canvas: canvas, + align: TextAlign.right, + color: widget.textColor == Colors.black ? Colors.white : Colors.black, + backgroundColor: widget.textColor, + text: ' ${formatPrice(selectedPoint['price'], 8)} ', + point: Offset(size.width - widget.labelWidth - 2, dy - 2), + width: widget.labelWidth, + ); - _drawText( - canvas: canvas, - align: TextAlign.center, - color: widget.textColor == Colors.black ? Colors.white : Colors.black, - backgroundColor: widget.textColor, - text: ' ${_formatTime(selectedCandle.closeTime * 1000)} ', - point: Offset(dx - 50, size.height - 7), - width: labelWidth, - ); + double startY = dy + radius; + while (startY < size.height - widget.marginBottom + 10) { + canvas.drawLine(Offset(dx, startY), Offset(dx, startY + 5), painter); + startY += 10; } + + _drawText( + canvas: canvas, + align: TextAlign.center, + color: widget.textColor == Colors.black ? Colors.white : Colors.black, + backgroundColor: widget.textColor, + text: ' ${_formatTime(selectedCandle.closeTime)} ', + point: Offset(dx - 50, size.height - 7), + width: widget.labelWidth, + ); } } // paint @override bool shouldRepaint(CustomPainter oldDelegate) { - return true; + final _ChartPainter old = oldDelegate as _ChartPainter; + + return timeAxisShift != old.timeAxisShift || + zoom != old.zoom || + selectedPoint != old.selectedPoint || + tapPosition != old.tapPosition || + widget.data.length != old.widget.data.length; } double _priceDivision(double range, double divisions) { @@ -515,61 +694,20 @@ class _ChartPainter extends CustomPainter { } double _price(double price) { - if (widget.quoted) return 1 / price; - return price; - } - - double _minPrice(List data) { - double minPrice; - - for (CandleData candle in data) { - final double lowest = [ - _price(candle.openPrice), - _price(candle.lowPrice), - _price(candle.closePrice), - ].reduce(min); - - if (minPrice == null || lowest < minPrice) { - minPrice = lowest; - } - } - - return minPrice; - } - - double _maxPrice(List data) { - double maxPrice; - - for (CandleData candle in data) { - final double highest = [ - _price(candle.openPrice), - _price(candle.highPrice), - _price(candle.closePrice), - ].reduce(max); - - if (maxPrice == null || highest > maxPrice) { - maxPrice = highest; - } + if (widget.quoted) { + return 1 / price; } - - return maxPrice; + return price; } String _formatTime(int millisecondsSinceEpoch) { final DateTime utc = DateTime.fromMillisecondsSinceEpoch( millisecondsSinceEpoch, - isUtc: false, + isUtc: true, ); - final bool thisYear = DateTime.now().year == - DateTime.fromMillisecondsSinceEpoch(millisecondsSinceEpoch).year; - String format = 'MMM dd yyyy'; - if (widget.duration < 60 * 60 * 24) - format = 'MMM dd${thisYear ? '' : ' yyyy'}, HH:00'; - if (widget.duration < 60 * 60) - format = 'MMM dd${thisYear ? '' : ' yyyy'}, HH:mm'; - - return DateFormat(format).format(utc); + const String format = 'MMM dd yyyy HH:mm'; + return DateFormat(format).format(utc.toLocal()); } void _drawText({ @@ -583,29 +721,27 @@ class _ChartPainter extends CustomPainter { }) { final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle(textAlign: align)) - ..pushStyle(TextStyle( - color: color, - fontSize: 10, - background: Paint()..color = backgroundColor, - )) + ..pushStyle( + TextStyle( + color: color, + fontSize: 10, + background: Paint()..color = backgroundColor, + ), + ) ..addText(text); final Paragraph paragraph = builder.build() ..layout(ParagraphConstraints(width: width)); canvas.drawParagraph( - paragraph, Offset(point.dx, point.dy - paragraph.height)); + paragraph, + Offset(point.dx, point.dy - paragraph.height), + ); } - List _sortByTime(List data) { - final List sortedByTime = - data.where((CandleData candle) => candle.closeTime != null).toList(); - - sortedByTime.sort((a, b) { - if (a.closeTime < b.closeTime) return 1; - if (a.closeTime > b.closeTime) return -1; - return 0; - }); - - return sortedByTime; + Paint _initializePaint() { + return Paint() + ..style = widget.filled ? PaintingStyle.fill : PaintingStyle.stroke + ..strokeWidth = widget.strokeWidth + ..strokeCap = StrokeCap.round; } } @@ -628,3 +764,33 @@ class CandlePosition { double left; double right; } + +class VisibleCandles { + VisibleCandles({ + @required this.candles, + @required this.gridData, + }); + + List candles; + CandleGridData gridData; +} + +class CandleGridData { + CandleGridData({ + @required this.originPrice, + @required this.priceScaleFactor, + @required this.priceDivision, + @required this.timeAxisMax, + @required this.timeAxisMin, + @required this.timeScaleFactor, + }); + + double minPrice; + double maxPrice; + double originPrice; + double priceScaleFactor; + double priceDivision; + double timeAxisMax; + double timeAxisMin; + double timeScaleFactor; +} diff --git a/lib/screens/markets/coins_price_list.dart b/lib/screens/markets/coins_price_list.dart index 82813934b..5c903e24e 100644 --- a/lib/screens/markets/coins_price_list.dart +++ b/lib/screens/markets/coins_price_list.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; + import '../../../blocs/coins_bloc.dart'; import '../../../model/coin.dart'; import '../../../model/coin_balance.dart'; -import '../markets/build_coin_price_list_item.dart'; -import '../../services/mm_service.dart'; - import '../../localizations.dart'; +import '../../services/mm_service.dart'; +import '../markets/build_coin_price_list_item.dart'; class CoinsPriceList extends StatefulWidget { const CoinsPriceList({this.onItemTap}); @@ -28,7 +28,7 @@ class _CoinsPriceListState extends State { @override Widget build(BuildContext context) { - return StreamBuilder( + return StreamBuilder>( stream: coinsBloc.outCoins, builder: (BuildContext context, AsyncSnapshot> snapshot) { @@ -39,7 +39,7 @@ class _CoinsPriceListState extends State { ? Center( child: Text( AppLocalizations.of(context).noCoinFound, - style: TextStyle(fontSize: 18), + style: const TextStyle(fontSize: 18), ), ) : Listener( @@ -54,21 +54,21 @@ class _CoinsPriceListState extends State { }); }, child: ListView.builder( - physics: touchCounter > 1 - ? const NeverScrollableScrollPhysics() - : const ScrollPhysics(), - shrinkWrap: true, - itemCount: _sortedList.length, - itemBuilder: (BuildContext context, int index) { - return BuildCoinPriceListItem( - key: Key('coin-' + - _sortedList[index].coin.abbr.toUpperCase()), - coinBalance: _sortedList[index], - onTap: () { - widget.onItemTap(_sortedList[index].coin); - }, - ); - }), + physics: touchCounter > 1 + ? const NeverScrollableScrollPhysics() + : const ScrollPhysics(), + shrinkWrap: true, + itemCount: _sortedList.length, + itemBuilder: (BuildContext context, int index) { + final Coin coin = _sortedList[index].coin; + final String coinAbbr = coin.abbr.toUpperCase(); + return BuildCoinPriceListItem( + key: Key('coin-$coinAbbr'), + coinBalance: _sortedList[index], + onTap: () => widget.onItemTap(coin), + ); + }, + ), ); } else { return const Center( diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 8103a013e..bafbb18d5 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -106,6 +106,12 @@ String getCoinTicker(String abbr) { return abbr; } +String getCoinTickerRegex(String abbr) { + final String suffixes = appConfig.protocolSuffixes.join('|'); + final RegExp regExp = RegExp('(_|-)($suffixes)'); + return abbr.replaceAll(regExp, ''); +} + Rational deci2rat(Decimal decimal) { try { return Rational.parse(decimal.toString()); diff --git a/test/coin_ticker_test.dart b/test/coin_ticker_test.dart new file mode 100644 index 000000000..aa8cf2535 --- /dev/null +++ b/test/coin_ticker_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_dex/app_config/app_config.dart'; +import 'package:komodo_dex/utils/utils.dart'; + +void main() { + group('getCoinTicker', () { + test('returns correct ticker for valid abbreviation', () { + final String ticker = getCoinTicker('BTC'); + expect(ticker, 'BTC'); + }); + + test('returns correct ticker without protocol suffix', () { + final List symbols = appConfig.protocolSuffixes + .map((String suffix) => 'KMD-$suffix') + .toList(); + final List tickers = + symbols.map((String symbol) => getCoinTicker(symbol)).toList(); + + expect(tickers, tickers.map((_) => 'KMD')); + }); + + test( + 'returns correct ticker without protocol suffix, without affecting other symbols', + () { + final List symbols = appConfig.protocolSuffixes + .map((String suffix) => 'KMD-$suffix old_$suffix') + .toList(); + final List tickers = + symbols.map((String symbol) => getCoinTicker(symbol)).toList(); + + expect(tickers, tickers.map((_) => 'KMD old').toList()); + }); + + test('returns the input for invalid abbreviation', () { + final String ticker = getCoinTicker('XYZ'); + expect(ticker, 'XYZ'); + }); + }); +}