From 8f5bd63f5c1c5a9fbb3f9c915436ae876ae253da Mon Sep 17 00:00:00 2001 From: Francois Date: Thu, 8 Feb 2024 12:06:39 +0100 Subject: [PATCH 01/17] Add Binance API provider and repository Includes - simple unit tests, and - repository functions that will plug into the legacy charts provider, `cex_provider.dart` --- .../bloc/binance_provider.dart | 57 ++++++ .../bloc/binance_repository.dart | 101 ++++++++++ .../models/binance_exchange_info.dart | 186 ++++++++++++++++++ .../models/binance_klines.dart | 88 +++++++++ .../test/binance_provider_test.dart | 49 +++++ .../test/binance_repository_test.dart | 46 +++++ 6 files changed, 527 insertions(+) create mode 100644 lib/packages/binance_candlestick_charts/bloc/binance_provider.dart create mode 100644 lib/packages/binance_candlestick_charts/bloc/binance_repository.dart create mode 100644 lib/packages/binance_candlestick_charts/models/binance_exchange_info.dart create mode 100644 lib/packages/binance_candlestick_charts/models/binance_klines.dart create mode 100644 lib/packages/binance_candlestick_charts/test/binance_provider_test.dart create mode 100644 lib/packages/binance_candlestick_charts/test/binance_repository_test.dart 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..73377c726 --- /dev/null +++ b/lib/packages/binance_candlestick_charts/bloc/binance_provider.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:komodo_dex/utils/log.dart'; + +import '../models/binance_exchange_info.dart'; +import '../models/binance_klines.dart'; + +class BinanceProvider { + BinanceProvider({this.apiUrl}); + + final String apiUrl; + + 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}', + ); + } + } + + 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'); + } + } +} 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..bb88411cf --- /dev/null +++ b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart @@ -0,0 +1,101 @@ +// Using relative imports in this "package" to make it easier to track external +// dependencies when moving or copying this "package" to another project. +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 defaultBinanceCandleIntervalsMap = { + '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', +}; +final List defaultBinanceCandleIntervals = + defaultBinanceCandleIntervalsMap.values.toList(); +final Map reverseBinanceCandleIntervalsMap = + defaultBinanceCandleIntervalsMap + .map((String k, String v) => MapEntry(v, k)); + +class BinanceRepository { + BinanceRepository({BinanceProvider binanceProvider}) + : _binanceProvider = + binanceProvider ?? BinanceProvider(apiUrl: binanceApiEndpoint); + + final BinanceProvider _binanceProvider; + + List _symbols = []; + + Future> getLegacyTickers() async { + if (_symbols.isNotEmpty) { + return _symbols; + } + + final BinanceExchangeInfoResponse exchangeInfo = + await _binanceProvider.fetchExchangeInfo(); + if (exchangeInfo != null) { + // The legacy candlestick implementation uses hyphenated lowercase + // symbols, so we convert the symbols to that format here. + _symbols = exchangeInfo.symbols + .map( + (Symbol symbol) => + '${symbol.baseAsset}-${symbol.quoteAsset}'.toLowerCase(), + ) + .toList(); + } + + return _symbols; + } + + Future> getLegacyOhlcCandleData( + String symbol, { + List intervals, + }) async { + final Map ohlcData = {}; + + // The Binance API requires the symbol to be in uppercase and without any + // special characters, so we remove them here. + symbol = normaliseSymbol(symbol); + intervals ??= defaultBinanceCandleIntervals; + + await Future.wait( + intervals.map( + (String interval) async { + final BinanceKlinesResponse klinesResponse = + await _binanceProvider.fetchKlines(symbol, interval); + final String ohlcInterval = + reverseBinanceCandleIntervalsMap[interval]; + + if (klinesResponse != null) { + ohlcData[ohlcInterval] = klinesResponse.klines + .map((BinanceKline kline) => kline.toMap()) + .toList(); + } else { + ohlcData[ohlcInterval] = >[]; + } + }, + ), + ); + + ohlcData['604800_Monday'] = ohlcData['604800']; + + return ohlcData; + } + + String normaliseSymbol(String symbol) { + // The Binance API requires the symbol to be in uppercase and without any + // special characters, so we remove them here. + symbol = symbol.replaceAll('-', '').replaceAll('/', '').toUpperCase(); + return symbol; + } +} 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..d78d507f5 --- /dev/null +++ b/lib/packages/binance_candlestick_charts/models/binance_exchange_info.dart @@ -0,0 +1,186 @@ +class BinanceExchangeInfoResponse { + BinanceExchangeInfoResponse({ + this.timezone, + this.serverTime, + this.rateLimits, + this.symbols, + }); + + 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(), + ); + } + + String timezone; + int serverTime; + List rateLimits; + List symbols; +} + +class RateLimit { + RateLimit({ + this.rateLimitType, + this.interval, + this.intervalNum, + this.limit, + }); + + RateLimit.fromJson(Map json) { + rateLimitType = json['rateLimitType']; + interval = json['interval']; + intervalNum = json['intervalNum']; + limit = json['limit']; + } + + String rateLimitType; + String interval; + int intervalNum; + int limit; +} + +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, + }); + + 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(), + ); + } + + String symbol; + String status; + String baseAsset; + int baseAssetPrecision; + String quoteAsset; + int quotePrecision; + int quoteAssetPrecision; + int baseCommissionPrecision; + int quoteCommissionPrecision; + List orderTypes; + bool icebergAllowed; + bool ocoAllowed; + bool quoteOrderQtyMarketAllowed; + bool allowTrailingStop; + bool cancelReplaceAllowed; + bool isSpotTradingAllowed; + bool isMarginTradingAllowed; + List filters; + List permissions; + String defaultSelfTradePreventionMode; + List allowedSelfTradePreventionModes; +} + +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, + }); + + 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'], + ); + } + + String filterType; + String minPrice; + String maxPrice; + String tickSize; + String minQty; + String maxQty; + String stepSize; + int limit; + String minNotional; + bool applyMinToMarket; + String maxNotional; + bool applyMaxToMarket; + int avgPriceMins; + int maxNumOrders; + 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..bc7172b85 --- /dev/null +++ b/lib/packages/binance_candlestick_charts/models/binance_klines.dart @@ -0,0 +1,88 @@ +class BinanceKlinesResponse { + BinanceKlinesResponse({this.klines}); + + factory BinanceKlinesResponse.fromJson(List json) { + return BinanceKlinesResponse( + klines: + json.map((dynamic kline) => BinanceKline.fromJson(kline)).toList(), + ); + } + + final List klines; + + List toJson() { + return klines.map((BinanceKline kline) => kline.toJson()).toList(); + } +} + +class BinanceKline { + BinanceKline({ + this.openTime, + this.open, + this.high, + this.low, + this.close, + this.volume, + this.closeTime, + this.quoteAssetVolume, + this.numberOfTrades, + this.takerBuyBaseAssetVolume, + this.takerBuyQuoteAssetVolume, + }); + + 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]), + ); + } + + final int openTime; + final double open; + final double high; + final double low; + final double close; + final double volume; + final int closeTime; + final double quoteAssetVolume; + final int numberOfTrades; + final double takerBuyBaseAssetVolume; + final double takerBuyQuoteAssetVolume; + + List toJson() { + return [ + openTime, + open, + high, + low, + close, + volume, + closeTime, + quoteAssetVolume, + numberOfTrades, + takerBuyBaseAssetVolume, + takerBuyQuoteAssetVolume, + ]; + } + + Map toMap() { + return { + 'timestamp': openTime, + 'open': open, + 'high': high, + 'low': low, + 'close': close, + 'volume': volume, + 'quote_volume': quoteAssetVolume + }; + } +} 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..02c9436a9 --- /dev/null +++ b/lib/packages/binance_candlestick_charts/test/binance_provider_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:komodo_dex/packages/binance_candlestick_charts/bloc/binance_provider.dart'; +import 'package:komodo_dex/packages/binance_candlestick_charts/models/binance_exchange_info.dart'; +import 'package:komodo_dex/packages/binance_candlestick_charts/models/binance_klines.dart'; + +void main() { + group('BinanceProvider', () { + const apiUrl = 'https://api.binance.com/api/v3'; + + test('fetchKlines returns BinanceKlinesResponse when successful', () async { + final provider = BinanceProvider(apiUrl: apiUrl); + final symbol = 'BTCUSDT'; + final interval = '1m'; + final limit = 100; + + final 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 provider = BinanceProvider(apiUrl: apiUrl); + final symbol = 'invalid_symbol'; + final interval = '1m'; + final limit = 100; + + expect( + () => provider.fetchKlines(symbol, interval, limit: limit), + throwsException, + ); + }); + + test('fetchExchangeInfo returns a valid object when successful', () async { + final provider = BinanceProvider(apiUrl: apiUrl); + + final 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..31012c8a1 --- /dev/null +++ b/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import '../bloc/binance_repository.dart'; + +void main() { + group('BinanceRepository', () { + final BinanceRepository repository = BinanceRepository(); + + test('getLegacyOhlcCandleData returns a valid map when successful', + () async { + // Prepare test data + final symbol = 'eth-btc'; + final limit = 100; + + // Call the method + final Map result = + await repository.getLegacyOhlcCandleData(symbol); + + final Map resultMin = + (result['60'] as List>).first; + print(resultMin); + + // 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 + final symbol = 'invalid_symbol/'; + final interval = '1m'; + final limit = 100; + + // Call the method and expect an exception to be thrown + expect( + () => repository.getLegacyOhlcCandleData(symbol), + throwsException, + ); + }); + }); +} From 928df5b8e7b0ec30b3785559e369a602894a51d9 Mon Sep 17 00:00:00 2001 From: Francois Date: Sat, 10 Feb 2024 12:44:56 +0100 Subject: [PATCH 02/17] Replace tickers and chart updating with Binance repository --- lib/model/cex_provider.dart | 70 +++++-------------- .../bloc/binance_repository.dart | 38 ++++++---- .../test/binance_provider_test.dart | 7 +- .../test/binance_repository_test.dart | 1 + 4 files changed, 46 insertions(+), 70 deletions(-) diff --git a/lib/model/cex_provider.dart b/lib/model/cex_provider.dart index 35a81237b..7dc1490d7 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'; @@ -17,7 +18,7 @@ import '../utils/utils.dart'; class CexProvider extends ChangeNotifier { CexProvider() { - _updateTickersList(); + _updateTickersListV2(); _updateRates(); cexPrices.linkProvider(this); @@ -92,44 +93,25 @@ class CexProvider extends ChangeNotifier { final Map _charts = {}; // {'BTC-USD': ChartData(),} bool _updatingChart = false; List _tickers; + final BinanceRepository _binanceRepository = BinanceRepository(); void _updateRates() => cexPrices.updateRates(); List _getTickers() { if (_tickers != null) return _tickers; - _updateTickersList(); + _updateTickersListV2(); return _tickersFallBack; } - Future _updateTickersList() async { - http.Response _res; - String _body; + Future _updateTickersListV2() async { try { - _res = await http.get(_tickersListUrl).timeout( - const Duration(seconds: 60), - onTimeout: () { - Log('cex_provider', 'Fetching tickers timed out'); - return; - }, - ); - _body = _res.body; + final List tickers = await _binanceRepository.getLegacyTickers(); + _tickers = tickers; + 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 { @@ -146,9 +128,9 @@ class CexProvider extends ChangeNotifier { _charts[pair].status = ChartStatus.fetching; } try { - json0 = await _fetchChartData(chain[0]); + json0 = await _fetchChartDataV2(chain[0]); if (chain.length > 1) { - json1 = await _fetchChartData(chain[1]); + json1 = await _fetchChartDataV2(chain[1]); } } catch (_) { _updatingChart = false; @@ -262,38 +244,22 @@ class CexProvider extends ChangeNotifier { notifyListeners(); } - Future> _fetchChartData(ChainLink link) async { - final String pair = '${link.rel}-${link.base}'; - http.Response _res; - String _body; + Future> _fetchChartDataV2(ChainLink link) async { 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 = getCoinTicker(pair); + final List abbr = pair.split('-'); if (abbr[0] == abbr[1]) return null; final String base = abbr[1].toLowerCase(); diff --git a/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart index bb88411cf..097a33b82 100644 --- a/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart +++ b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart @@ -1,5 +1,7 @@ // 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'; @@ -21,11 +23,6 @@ const Map defaultBinanceCandleIntervalsMap = { '259200': '3d', '604800': '1w', }; -final List defaultBinanceCandleIntervals = - defaultBinanceCandleIntervalsMap.values.toList(); -final Map reverseBinanceCandleIntervalsMap = - defaultBinanceCandleIntervalsMap - .map((String k, String v) => MapEntry(v, k)); class BinanceRepository { BinanceRepository({BinanceProvider binanceProvider}) @@ -59,29 +56,40 @@ class BinanceRepository { Future> getLegacyOhlcCandleData( String symbol, { - List intervals, + List ohlcDurations, }) async { - final Map ohlcData = {}; + final Map ohlcData = { + ...defaultBinanceCandleIntervalsMap + }; // The Binance API requires the symbol to be in uppercase and without any // special characters, so we remove them here. symbol = normaliseSymbol(symbol); - intervals ??= defaultBinanceCandleIntervals; + ohlcDurations ??= defaultBinanceCandleIntervalsMap.keys.toList(); await Future.wait( - intervals.map( - (String interval) async { + ohlcDurations.map( + (String duration) async { + // final int startTime = DateTime.now() + // .toUtc() + // .subtract( + // Duration(seconds: int.parse(duration)), + // ) + // .millisecondsSinceEpoch; final BinanceKlinesResponse klinesResponse = - await _binanceProvider.fetchKlines(symbol, interval); - final String ohlcInterval = - reverseBinanceCandleIntervalsMap[interval]; + await _binanceProvider.fetchKlines( + symbol, + defaultBinanceCandleIntervalsMap[duration], + limit: 500, + // startTime: startTime, + ); if (klinesResponse != null) { - ohlcData[ohlcInterval] = klinesResponse.klines + ohlcData[duration] = klinesResponse.klines .map((BinanceKline kline) => kline.toMap()) .toList(); } else { - ohlcData[ohlcInterval] = >[]; + ohlcData[duration] = >[]; } }, ), diff --git a/lib/packages/binance_candlestick_charts/test/binance_provider_test.dart b/lib/packages/binance_candlestick_charts/test/binance_provider_test.dart index 02c9436a9..4a6f86449 100644 --- a/lib/packages/binance_candlestick_charts/test/binance_provider_test.dart +++ b/lib/packages/binance_candlestick_charts/test/binance_provider_test.dart @@ -1,8 +1,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; -import 'package:komodo_dex/packages/binance_candlestick_charts/bloc/binance_provider.dart'; -import 'package:komodo_dex/packages/binance_candlestick_charts/models/binance_exchange_info.dart'; -import 'package:komodo_dex/packages/binance_candlestick_charts/models/binance_klines.dart'; + +import '../bloc/binance_provider.dart'; +import '../models/binance_exchange_info.dart'; +import '../models/binance_klines.dart'; void main() { group('BinanceProvider', () { diff --git a/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart b/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart index 31012c8a1..1d6cae846 100644 --- a/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart +++ b/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; + import '../bloc/binance_repository.dart'; void main() { From 5c1414e063d0365a616e5980907472f7d797d86c Mon Sep 17 00:00:00 2001 From: Francois Date: Sun, 11 Feb 2024 18:59:59 +0100 Subject: [PATCH 03/17] Fix candlestick chart zoom and scaling --- .../bloc/binance_repository.dart | 17 ++++------- lib/screens/markets/candlestick_chart.dart | 29 +++++++++++++------ 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart index 097a33b82..12cd8c7d0 100644 --- a/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart +++ b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart @@ -8,7 +8,7 @@ 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 defaultBinanceCandleIntervalsMap = { +const Map defaultCandleIntervalsBinanceMap = { '60': '1m', '180': '3m', '300': '5m', @@ -59,29 +59,22 @@ class BinanceRepository { List ohlcDurations, }) async { final Map ohlcData = { - ...defaultBinanceCandleIntervalsMap + ...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 ??= defaultBinanceCandleIntervalsMap.keys.toList(); + ohlcDurations ??= defaultCandleIntervalsBinanceMap.keys.toList(); await Future.wait( ohlcDurations.map( (String duration) async { - // final int startTime = DateTime.now() - // .toUtc() - // .subtract( - // Duration(seconds: int.parse(duration)), - // ) - // .millisecondsSinceEpoch; final BinanceKlinesResponse klinesResponse = await _binanceProvider.fetchKlines( symbol, - defaultBinanceCandleIntervalsMap[duration], - limit: 500, - // startTime: startTime, + defaultCandleIntervalsBinanceMap[duration], + limit: 500, // The default is 500, and the max is 1000 for Binance. ); if (klinesResponse != null) { diff --git a/lib/screens/markets/candlestick_chart.dart b/lib/screens/markets/candlestick_chart.dart index 7e560da5d..aed9861d5 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -223,25 +223,36 @@ class _ChartPainter extends CustomPainter { 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; + int visibleCandles = (size.width / (candleWidth + gap) / zoom).floor(); + if (visibleCandles < 1) { + visibleCandles = 1; + } + if (visibleCandles > data.length) { + visibleCandles = data.length; + } + final int timeRange = + data[0].closeTime - data[visibleCandles - 1].closeTime; final double timeScaleFactor = size.width / timeRange; setWidgetState( - 'maxTimeShift', - (data.first.closeTime - data.last.closeTime) * timeScaleFactor - - timeRange * timeScaleFactor); + 'maxTimeShift', + (data.first.closeTime - data.last.closeTime) * timeScaleFactor - + timeRange * timeScaleFactor, + ); final double timeAxisMax = data[0].closeTime - timeAxisShift * zoom / timeScaleFactor; final double timeAxisMin = timeAxisMax - timeRange; //collect visible candles data final List visibleCandlesData = []; - for (int i = 0; i < data.length; i++) { + for (int i = 0; i < visibleCandles; i++) { final CandleData candle = data[i]; final double dx = (candle.closeTime - timeAxisMin) * timeScaleFactor; - if (dx > size.width + candleWidth * zoom) continue; - if (dx < 0) break; + if (dx > size.width + candleWidth * zoom) { + continue; + } + if (dx < 0) { + break; + } visibleCandlesData.add(candle); } From e0f7fa062a77ecfea52c7848629aeb0b9b7cf886 Mon Sep 17 00:00:00 2001 From: Francois Date: Sun, 11 Feb 2024 19:13:32 +0100 Subject: [PATCH 04/17] Fix year text on timescale --- lib/screens/markets/candlestick_chart.dart | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/screens/markets/candlestick_chart.dart b/lib/screens/markets/candlestick_chart.dart index aed9861d5..8cd9b50e5 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -385,7 +385,7 @@ class _ChartPainter extends CustomPainter { rightMarkerPosition - labelWidth - 4, size.height - 7, ), - text: _formatTime(visibleCandlesData.first.closeTime * 1000), + text: _formatTime(visibleCandlesData.first.closeTime), align: TextAlign.end, width: labelWidth, ); @@ -396,7 +396,7 @@ class _ChartPainter extends CustomPainter { 4, size.height - 7, ), - text: _formatTime(visibleCandlesData.last.closeTime * 1000), + text: _formatTime(visibleCandlesData.last.closeTime), align: TextAlign.start, width: labelWidth, ); @@ -485,7 +485,7 @@ class _ChartPainter extends CustomPainter { align: TextAlign.center, color: widget.textColor == Colors.black ? Colors.white : Colors.black, backgroundColor: widget.textColor, - text: ' ${_formatTime(selectedCandle.closeTime * 1000)} ', + text: ' ${_formatTime(selectedCandle.closeTime)} ', point: Offset(dx - 50, size.height - 7), width: labelWidth, ); @@ -569,16 +569,17 @@ class _ChartPainter extends CustomPainter { String _formatTime(int millisecondsSinceEpoch) { final DateTime utc = DateTime.fromMillisecondsSinceEpoch( millisecondsSinceEpoch, - isUtc: false, + isUtc: true, ); - final bool thisYear = DateTime.now().year == - DateTime.fromMillisecondsSinceEpoch(millisecondsSinceEpoch).year; + final bool thisYear = DateTime.now().year == utc.year; String format = 'MMM dd yyyy'; - if (widget.duration < 60 * 60 * 24) + if (widget.duration < 60 * 60 * 24) { format = 'MMM dd${thisYear ? '' : ' yyyy'}, HH:00'; - if (widget.duration < 60 * 60) + } + if (widget.duration < 60 * 60) { format = 'MMM dd${thisYear ? '' : ' yyyy'}, HH:mm'; + } return DateFormat(format).format(utc); } From 6ee700b9f674a1c9185d45558828b41489c7ab3d Mon Sep 17 00:00:00 2001 From: Francois Date: Mon, 12 Feb 2024 11:27:02 +0100 Subject: [PATCH 05/17] Fix candlestick scrolling Allow for scrolling over a larger time range than the currently visible candles, up to a maximum of the number of data points retrieved from the Binance API. --- lib/screens/markets/candlestick_chart.dart | 29 +++++++++++----------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/lib/screens/markets/candlestick_chart.dart b/lib/screens/markets/candlestick_chart.dart index 8cd9b50e5..0e9255095 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -222,29 +222,28 @@ class _ChartPainter extends CustomPainter { const double labelWidth = 100; final double fieldHeight = size.height - marginBottom - marginTop; - // adjust time asix - int visibleCandles = (size.width / (candleWidth + gap) / zoom).floor(); - if (visibleCandles < 1) { - visibleCandles = 1; - } - if (visibleCandles > data.length) { - visibleCandles = data.length; - } - final int timeRange = - data[0].closeTime - data[visibleCandles - 1].closeTime; + // adjust time axis + final int maxVisibleCandles = + (size.width / (candleWidth + gap) / zoom).floor(); + final int firstCandleIndex = + timeAxisShift.floor().clamp(0, data.length - maxVisibleCandles); + final int lastVisibleCandleIndex = (firstCandleIndex + maxVisibleCandles) + .clamp(maxVisibleCandles, data.length - 1); + + final int firstCandleCloseTime = data[firstCandleIndex].closeTime; + final int lastCandleCloseTime = data[lastVisibleCandleIndex].closeTime; + final int timeRange = firstCandleCloseTime - lastCandleCloseTime; final double timeScaleFactor = size.width / timeRange; setWidgetState( 'maxTimeShift', - (data.first.closeTime - data.last.closeTime) * timeScaleFactor - - timeRange * timeScaleFactor, + (data.length - maxVisibleCandles).toDouble(), ); - final double timeAxisMax = - data[0].closeTime - timeAxisShift * zoom / timeScaleFactor; + final double timeAxisMax = firstCandleCloseTime - zoom / timeScaleFactor; final double timeAxisMin = timeAxisMax - timeRange; //collect visible candles data final List visibleCandlesData = []; - for (int i = 0; i < visibleCandles; i++) { + for (int i = firstCandleIndex; i < lastVisibleCandleIndex; i++) { final CandleData candle = data[i]; final double dx = (candle.closeTime - timeAxisMin) * timeScaleFactor; if (dx > size.width + candleWidth * zoom) { From f199bd224656bb77ecd1c2bdf74db40c85decc12 Mon Sep 17 00:00:00 2001 From: Francois Date: Mon, 12 Feb 2024 16:16:17 +0100 Subject: [PATCH 06/17] Add comments and remove unused imports With the assistance of GitHub Copilot --- .../bloc/binance_provider.dart | 34 ++++++- .../bloc/binance_repository.dart | 32 ++++++- .../models/binance_exchange_info.dart | 92 +++++++++++++++++++ .../models/binance_klines.dart | 55 ++++++++--- .../test/binance_provider_test.dart | 25 ++--- .../test/binance_repository_test.dart | 10 +- 6 files changed, 211 insertions(+), 37 deletions(-) diff --git a/lib/packages/binance_candlestick_charts/bloc/binance_provider.dart b/lib/packages/binance_candlestick_charts/bloc/binance_provider.dart index 73377c726..0058a5099 100644 --- a/lib/packages/binance_candlestick_charts/bloc/binance_provider.dart +++ b/lib/packages/binance_candlestick_charts/bloc/binance_provider.dart @@ -1,16 +1,39 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import 'package:komodo_dex/utils/log.dart'; 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}); + 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, Unix time) of the data range to fetch (optional). + /// - [endTime]: The end time (in milliseconds, Unix time) of the data range to fetch (optional). + /// - [limit]: The maximum number of data points to fetch (optional). + /// + /// 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, { @@ -41,6 +64,10 @@ class BinanceProvider { } } + /// 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'), @@ -51,7 +78,8 @@ class BinanceProvider { jsonDecode(response.body), ); } else { - throw Exception('Failed to load symbols'); + 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 index 12cd8c7d0..c3649cc36 100644 --- a/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart +++ b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart @@ -24,6 +24,8 @@ const Map defaultCandleIntervalsBinanceMap = { '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 = @@ -33,6 +35,13 @@ class BinanceRepository { List _symbols = []; + /// 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 { if (_symbols.isNotEmpty) { return _symbols; @@ -54,6 +63,22 @@ class BinanceRepository { 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. + /// + /// Returns: + /// A map of durations to the corresponding candle data. + /// + /// Example usage: + /// ```dart + /// final Map candleData = await getLegacyOhlcCandleData('BTCUSDT', ohlcDurations: ['1m', '5m', '1h']); + /// ``` Future> getLegacyOhlcCandleData( String symbol, { List ohlcDurations, @@ -93,9 +118,12 @@ class BinanceRepository { 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) { - // The Binance API requires the symbol to be in uppercase and without any - // special characters, so we remove them here. symbol = symbol.replaceAll('-', '').replaceAll('/', '').toUpperCase(); return symbol; } diff --git a/lib/packages/binance_candlestick_charts/models/binance_exchange_info.dart b/lib/packages/binance_candlestick_charts/models/binance_exchange_info.dart index d78d507f5..529000a5b 100644 --- a/lib/packages/binance_candlestick_charts/models/binance_exchange_info.dart +++ b/lib/packages/binance_candlestick_charts/models/binance_exchange_info.dart @@ -1,3 +1,4 @@ +/// Represents the response from the Binance Exchange Info API. class BinanceExchangeInfoResponse { BinanceExchangeInfoResponse({ this.timezone, @@ -6,6 +7,7 @@ class BinanceExchangeInfoResponse { this.symbols, }); + /// Creates a new instance of [BinanceExchangeInfoResponse] from a JSON map. factory BinanceExchangeInfoResponse.fromJson(Map json) { return BinanceExchangeInfoResponse( timezone: json['timezone'], @@ -19,12 +21,20 @@ class BinanceExchangeInfoResponse { ); } + /// 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, @@ -33,6 +43,7 @@ class RateLimit { this.limit, }); + /// Creates a new instance of [RateLimit] from a JSON map. RateLimit.fromJson(Map json) { rateLimitType = json['rateLimitType']; interval = json['interval']; @@ -40,12 +51,20 @@ class RateLimit { 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, @@ -71,6 +90,7 @@ class Symbol { this.allowedSelfTradePreventionModes, }); + /// Creates a new instance of [Symbol] from a JSON map. factory Symbol.fromJson(Map json) { return Symbol( symbol: json['symbol'], @@ -106,29 +126,71 @@ class Symbol { ); } + /// 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, @@ -148,6 +210,7 @@ class Filter { this.maxNumAlgoOrders, }); + /// Creates a new instance of [Filter] from a JSON map. factory Filter.fromJson(Map json) { return Filter( filterType: json['filterType'], @@ -168,19 +231,48 @@ class Filter { ); } + /// 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 index bc7172b85..813bb647c 100644 --- a/lib/packages/binance_candlestick_charts/models/binance_klines.dart +++ b/lib/packages/binance_candlestick_charts/models/binance_klines.dart @@ -1,6 +1,9 @@ +/// 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: @@ -8,14 +11,18 @@ class BinanceKlinesResponse { ); } + /// 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, @@ -30,6 +37,7 @@ class BinanceKline { this.takerBuyQuoteAssetVolume, }); + /// Creates a new instance of [BinanceKline] from a JSON array. factory BinanceKline.fromJson(List json) { return BinanceKline( openTime: json[0], @@ -46,18 +54,7 @@ class BinanceKline { ); } - final int openTime; - final double open; - final double high; - final double low; - final double close; - final double volume; - final int closeTime; - final double quoteAssetVolume; - final int numberOfTrades; - final double takerBuyBaseAssetVolume; - final double takerBuyQuoteAssetVolume; - + /// Converts the [BinanceKline] object to a JSON array. List toJson() { return [ openTime, @@ -74,6 +71,7 @@ class BinanceKline { ]; } + /// Converts the kline data into a JSON object like that returned in the previously used OHLC endpoint. Map toMap() { return { 'timestamp': openTime, @@ -85,4 +83,37 @@ class BinanceKline { '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 index 4a6f86449..e7f8d1f63 100644 --- a/lib/packages/binance_candlestick_charts/test/binance_provider_test.dart +++ b/lib/packages/binance_candlestick_charts/test/binance_provider_test.dart @@ -1,5 +1,4 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; import '../bloc/binance_provider.dart'; import '../models/binance_exchange_info.dart'; @@ -10,22 +9,23 @@ void main() { const apiUrl = 'https://api.binance.com/api/v3'; test('fetchKlines returns BinanceKlinesResponse when successful', () async { - final provider = BinanceProvider(apiUrl: apiUrl); - final symbol = 'BTCUSDT'; - final interval = '1m'; - final limit = 100; + final BinanceProvider provider = BinanceProvider(apiUrl: apiUrl); + const String symbol = 'BTCUSDT'; + const String interval = '1m'; + const int limit = 100; - final result = await provider.fetchKlines(symbol, interval, limit: limit); + 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 provider = BinanceProvider(apiUrl: apiUrl); - final symbol = 'invalid_symbol'; - final interval = '1m'; - final limit = 100; + 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), @@ -34,9 +34,10 @@ void main() { }); test('fetchExchangeInfo returns a valid object when successful', () async { - final provider = BinanceProvider(apiUrl: apiUrl); + final BinanceProvider provider = BinanceProvider(apiUrl: apiUrl); - final result = await provider.fetchExchangeInfo(); + final BinanceExchangeInfoResponse result = + await provider.fetchExchangeInfo(); expect(result, isA()); expect(result.timezone, isA()); diff --git a/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart b/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart index 1d6cae846..b07677d08 100644 --- a/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart +++ b/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart @@ -1,5 +1,4 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; import '../bloc/binance_repository.dart'; @@ -9,9 +8,7 @@ void main() { test('getLegacyOhlcCandleData returns a valid map when successful', () async { - // Prepare test data - final symbol = 'eth-btc'; - final limit = 100; + const String symbol = 'eth-btc'; // Call the method final Map result = @@ -19,7 +16,6 @@ void main() { final Map resultMin = (result['60'] as List>).first; - print(resultMin); // Perform assertions expect(result, isA>()); @@ -33,9 +29,7 @@ void main() { test('getLegacyOhlcCandleData throws an exception when unsuccessful', () async { // Prepare test data - final symbol = 'invalid_symbol/'; - final interval = '1m'; - final limit = 100; + const String symbol = 'invalid_symbol/'; // Call the method and expect an exception to be thrown expect( From 277633ff156f6998806c9e6a67a3bcb5f6f76340 Mon Sep 17 00:00:00 2001 From: Francois Date: Mon, 12 Feb 2024 16:54:14 +0100 Subject: [PATCH 07/17] Reduce candlestick scroll speed --- lib/screens/markets/candlestick_chart.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/screens/markets/candlestick_chart.dart b/lib/screens/markets/candlestick_chart.dart index 0e9255095..c5149c61c 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -46,6 +46,7 @@ class CandleChartState extends State Size canvasSize; Offset tapPosition; Map selectedPoint; // {'timestamp': int, 'price': double} + int scrollDragFactor = 5; @override void initState() { @@ -112,7 +113,9 @@ class CandleChartState extends State setState(() { timeAxisShift = _constrainedTimeShift( - timeAxisShift + drag.delta.dx / staticZoom / dynamicZoom); + timeAxisShift + + drag.delta.dx / scrollDragFactor / staticZoom / dynamicZoom, + ); }); }, onScaleStart: (_) { @@ -129,11 +132,13 @@ class CandleChartState extends State onScaleUpdate: (ScaleUpdateDetails scale) { setState(() { dynamicZoom = _constrainedZoom(scale.scale); - timeAxisShift = _constrainedTimeShift(prevTimeAxisShift - - canvasSize.width / - 2 * - (1 - dynamicZoom) / - (staticZoom * dynamicZoom)); + timeAxisShift = _constrainedTimeShift( + prevTimeAxisShift - + canvasSize.width / + 2 * + (1 - dynamicZoom) / + (staticZoom * dynamicZoom), + ); }); }, onTapDown: (TapDownDetails details) { From 93c39d5245667b3a5d02259a726a119306ce0483 Mon Sep 17 00:00:00 2001 From: Francois Date: Mon, 12 Feb 2024 19:02:32 +0100 Subject: [PATCH 08/17] Fix candlestick hitching after zoom change --- lib/screens/markets/candlestick_chart.dart | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/lib/screens/markets/candlestick_chart.dart b/lib/screens/markets/candlestick_chart.dart index c5149c61c..d6800d828 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -67,17 +67,13 @@ class CandleChartState extends State @override Widget build(BuildContext context) { double _constrainedTimeShift(double timeShift) { - const overScroll = 70; + const int overScroll = 70; + const double minTimeShift = 0; //-overScroll / staticZoom / dynamicZoom; + final double maxTimeShiftValue = maxTimeShift != null + ? (maxTimeShift + overScroll) / staticZoom / dynamicZoom + : timeShift; - 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; + return timeShift.clamp(minTimeShift, maxTimeShiftValue); } double _constrainedZoom(double scale) { From 2bd405123d61b30da0e0d5c2eed7c80c1abfa189 Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 13 Feb 2024 18:26:08 +0100 Subject: [PATCH 09/17] Improve candlestick chart performance - Add conditions to `shouldRepaint` override to reduce the number of repaint calls - Optimise min and max price calculations to reduce loop iterations. - Use regex for string matching to improve performance, compared to multiple `replaceAll` calls - Reset timeAxisScale and zoom on duration change - Limit the maximum number of visible candles to 100 --- lib/model/cex_provider.dart | 24 +- .../bloc/binance_repository.dart | 14 +- .../test/binance_repository_test.dart | 24 ++ lib/screens/markets/candlestick_chart.dart | 272 +++++++++--------- lib/utils/utils.dart | 6 + test/coin_ticker_test.dart | 39 +++ 6 files changed, 228 insertions(+), 151 deletions(-) create mode 100644 test/coin_ticker_test.dart diff --git a/lib/model/cex_provider.dart b/lib/model/cex_provider.dart index 7dc1490d7..2c7d4ca69 100644 --- a/lib/model/cex_provider.dart +++ b/lib/model/cex_provider.dart @@ -118,7 +118,9 @@ class CexProvider extends ChangeNotifier { 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; @@ -248,7 +250,7 @@ class CexProvider extends ChangeNotifier { try { final String pair = '${link.rel}-${link.base}'; final Map result = - await _binanceRepository.getLegacyOhlcCandleData( pair); + await _binanceRepository.getLegacyOhlcCandleData(pair); return result; } catch (e) { Log('cex_provider', 'Failed to fetch data: $e'); @@ -258,7 +260,7 @@ class CexProvider extends ChangeNotifier { List _findChain(String pair) { // remove cex postfixes - pair = getCoinTicker(pair); + pair = getCoinTickerRegex(pair); final List abbr = pair.split('-'); if (abbr[0] == abbr[1]) return null; @@ -615,16 +617,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 diff --git a/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart index c3649cc36..99eb90da1 100644 --- a/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart +++ b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart @@ -71,6 +71,7 @@ class BinanceRepository { /// 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 250. /// /// Returns: /// A map of durations to the corresponding candle data. @@ -82,6 +83,7 @@ class BinanceRepository { Future> getLegacyOhlcCandleData( String symbol, { List ohlcDurations, + int limit = 500, }) async { final Map ohlcData = { ...defaultCandleIntervalsBinanceMap @@ -99,10 +101,17 @@ class BinanceRepository { await _binanceProvider.fetchKlines( symbol, defaultCandleIntervalsBinanceMap[duration], - limit: 500, // The default is 500, and the max is 1000 for Binance. + limit: + limit, // The default is 500, and the max is 1000 for Binance. ); 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(); @@ -124,7 +133,6 @@ class BinanceRepository { /// This method removes any dashes or slashes from the symbol and converts it to uppercase. /// Returns the normalized symbol. String normaliseSymbol(String symbol) { - symbol = symbol.replaceAll('-', '').replaceAll('/', '').toUpperCase(); - return symbol; + return symbol.replaceAll(RegExp(r'[-/]'), '').toUpperCase(); } } diff --git a/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart b/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart index b07677d08..c49742fdc 100644 --- a/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart +++ b/lib/packages/binance_candlestick_charts/test/binance_repository_test.dart @@ -37,5 +37,29 @@ void main() { 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/candlestick_chart.dart b/lib/screens/markets/candlestick_chart.dart index d6800d828..3f18ce352 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' hide TextStyle; import 'package:intl/intl.dart'; import '../../model/cex_provider.dart'; import '../../utils/utils.dart'; +import 'dart:developer' as developer; class CandleChart extends StatefulWidget { const CandleChart({ @@ -61,19 +62,19 @@ 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 int overScroll = 70; - const double minTimeShift = 0; //-overScroll / staticZoom / dynamicZoom; - final double maxTimeShiftValue = maxTimeShift != null - ? (maxTimeShift + overScroll) / staticZoom / dynamicZoom - : timeShift; - - return timeShift.clamp(minTimeShift, maxTimeShiftValue); + final double maxTimeShiftValue = maxTimeShift ?? timeShift; + return timeShift.clamp(0.0, maxTimeShiftValue); } double _constrainedZoom(double scale) { @@ -105,7 +106,9 @@ class CandleChartState extends State }, child: GestureDetector( onHorizontalDragUpdate: (DragUpdateDetails drag) { - if (touchCounter > 1) return; + if (touchCounter > 1) { + return; + } setState(() { timeAxisShift = _constrainedTimeShift( @@ -201,7 +204,12 @@ class _ChartPainter extends CustomPainter { this.tapPosition, this.selectedPoint, this.setWidgetState, - }); + }) { + painter = Paint() + ..style = widget.filled ? PaintingStyle.fill : PaintingStyle.stroke + ..strokeWidth = widget.strokeWidth + ..strokeCap = StrokeCap.round; + } final CandleChart widget; final double timeAxisShift; @@ -210,55 +218,69 @@ class _ChartPainter extends CustomPainter { final Map selectedPoint; final Function(String, dynamic) setWidgetState; + Paint painter; + final double pricePaddingPercent = 15; + final double pricePreferredDivisions = 4; + final double gap = 2; + final double marginTop = 14; + final double marginBottom = 30; + final double labelWidth = 100; + final int visibleCandlesLimit = 100; + @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; + final List visibleCandlesData = []; + double minPrice = double.infinity; + double maxPrice = 0; // adjust time axis final int maxVisibleCandles = - (size.width / (candleWidth + gap) / zoom).floor(); + (size.width / (widget.candleWidth + gap) / zoom) + .floor() + .clamp(0, visibleCandlesLimit); final int firstCandleIndex = - timeAxisShift.floor().clamp(0, data.length - maxVisibleCandles); + timeAxisShift.floor().clamp(0, widget.data.length - maxVisibleCandles); final int lastVisibleCandleIndex = (firstCandleIndex + maxVisibleCandles) - .clamp(maxVisibleCandles, data.length - 1); + .clamp(maxVisibleCandles - 1, widget.data.length - 1); - final int firstCandleCloseTime = data[firstCandleIndex].closeTime; - final int lastCandleCloseTime = data[lastVisibleCandleIndex].closeTime; + 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; setWidgetState( 'maxTimeShift', - (data.length - maxVisibleCandles).toDouble(), + (widget.data.length - maxVisibleCandles).toDouble(), ); final double timeAxisMax = firstCandleCloseTime - zoom / timeScaleFactor; final double timeAxisMin = timeAxisMax - timeRange; - //collect visible candles data - final List visibleCandlesData = []; + // Collect visible candles data for (int i = firstCandleIndex; i < lastVisibleCandleIndex; i++) { - final CandleData candle = data[i]; + final CandleData candle = widget.data[i]; final double dx = (candle.closeTime - timeAxisMin) * timeScaleFactor; - if (dx > size.width + candleWidth * zoom) { + if (dx > size.width + widget.candleWidth * zoom) { continue; } - if (dx < 0) { + 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); final double priceRange = maxPrice - minPrice; final double priceAxis = priceRange + (2 * priceRange * pricePaddingPercent / 100); @@ -280,24 +302,21 @@ class _ChartPainter extends CustomPainter { // returns dx for given time double _time2dx(int time) { return (time.toDouble() - timeAxisMin) * timeScaleFactor - - (candleWidth + gap) * zoom / 2; + (widget.candleWidth + gap) * zoom / 2; } - final Paint paint = Paint() - ..style = widget.filled ? PaintingStyle.fill : PaintingStyle.stroke - ..strokeWidth = widget.strokeWidth - ..strokeCap = StrokeCap.round; - // calculate candles position final Map candlesToRender = {}; - for (CandleData candle in visibleCandlesData) { + for (final CandleData candle in visibleCandlesData) { final double dx = _time2dx(candle.closeTime); 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; + if (bottom - top < widget.strokeWidth) { + bottom = top + widget.strokeWidth; + } candlesToRender[candle.closeTime] = CandlePosition( color: _price(candle.closePrice) < _price(candle.openPrice) @@ -305,8 +324,8 @@ class _ChartPainter extends CustomPainter { : 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, ); @@ -314,32 +333,37 @@ class _ChartPainter extends CustomPainter { // draw candles candlesToRender.forEach((int timeStamp, CandlePosition candle) { - _drawCandle(canvas, paint, candle); + _drawCandle(canvas, painter, candle); }); // draw price grid final int visibleDivisions = (size.height / (priceDivision * priceScaleFactor)).floor() + 1; for (int i = 0; i < visibleDivisions; i++) { - paint.color = widget.gridColor; + painter.color = widget.gridColor; final double price = originPrice + i * 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, ); } //draw current price - final double currentPrice = _price(data.first.closePrice); + final double currentPrice = + _price(widget.data[firstCandleIndex].closePrice); double currentPriceDy = _price2dy(currentPrice); bool outside = false; if (currentPriceDy > size.height - marginBottom) { @@ -353,12 +377,15 @@ class _ChartPainter extends CustomPainter { 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; } @@ -376,11 +403,11 @@ class _ChartPainter extends CustomPainter { double rightMarkerPosition = size.width; if (timeAxisShift < 0) { rightMarkerPosition = rightMarkerPosition - - (candleWidth / 2 + gap / 2 - timeAxisShift) * zoom; + (widget.candleWidth / 2 + gap / 2 - timeAxisShift) * zoom; } _drawText( canvas: canvas, - color: widget.textColor, //widget.textColor, + color: widget.textColor, point: Offset( rightMarkerPosition - labelWidth - 4, size.height - 7, @@ -391,7 +418,7 @@ class _ChartPainter extends CustomPainter { ); _drawText( canvas: canvas, - color: widget.textColor, //widget.textColor, + color: widget.textColor, point: Offset( 4, size.height - 7, @@ -400,23 +427,32 @@ class _ChartPainter extends CustomPainter { align: TextAlign.start, width: 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 - marginBottom), + Offset(dx, size.height - 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 - marginBottom), + Offset(0, size.height - marginBottom + 5), + painter, + ); + canvas.drawLine( + Offset(rightMarkerPosition, size.height - marginBottom), + Offset(rightMarkerPosition, size.height - marginBottom + 5), + painter, + ); // select point on Tap if (tapPosition != null) { setWidgetState('selectedPoint', null); double minDistance; - for (CandleData candle in visibleCandlesData) { + for (final CandleData candle in visibleCandlesData) { final List prices = [ _price(candle.openPrice), _price(candle.closePrice), @@ -424,12 +460,17 @@ class _ChartPainter extends CustomPainter { _price(candle.lowPrice), ].toList(); - for (double price in prices) { + 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; + 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', { @@ -454,13 +495,13 @@ class _ChartPainter extends CustomPainter { const double radius = 3; final double dx = _time2dx(selectedCandle.closeTime); final double dy = _price2dy(selectedPoint['price']); - paint.style = PaintingStyle.stroke; + painter.style = PaintingStyle.stroke; - canvas.drawCircle(Offset(dx, dy), radius, paint); + canvas.drawCircle(Offset(dx, dy), radius, painter); double startX = dx + radius; while (startX < size.width) { - canvas.drawLine(Offset(startX, dy), Offset(startX + 5, dy), paint); + canvas.drawLine(Offset(startX, dy), Offset(startX + 5, dy), painter); startX += 10; } @@ -476,7 +517,7 @@ class _ChartPainter extends CustomPainter { double startY = dy + radius; while (startY < size.height - marginBottom + 10) { - canvas.drawLine(Offset(dx, startY), Offset(dx, startY + 5), paint); + canvas.drawLine(Offset(dx, startY), Offset(dx, startY + 5), painter); startY += 10; } @@ -495,7 +536,13 @@ class _ChartPainter extends CustomPainter { @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) { @@ -526,44 +573,10 @@ 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) { @@ -571,16 +584,8 @@ class _ChartPainter extends CustomPainter { millisecondsSinceEpoch, isUtc: true, ); - final bool thisYear = DateTime.now().year == utc.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'; - } + const String format = 'MMM dd yyyy HH:mm'; return DateFormat(format).format(utc); } @@ -595,29 +600,20 @@ 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)); - } - - 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; + paragraph, + Offset(point.dx, point.dy - paragraph.height), + ); } } 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'); + }); + }); +} From 7cf49c2c2e847b6c6f31ce07fd2729ad84b7d972 Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 14 Feb 2024 15:16:45 +0100 Subject: [PATCH 10/17] Fix zoom timescale shift When zooming in, the time axis jumped far to the left instead. It will now stick to the right time axis --- lib/screens/markets/candlestick_chart.dart | 444 ++++++++++++--------- 1 file changed, 252 insertions(+), 192 deletions(-) diff --git a/lib/screens/markets/candlestick_chart.dart b/lib/screens/markets/candlestick_chart.dart index 3f18ce352..097f63e4d 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart' hide TextStyle; import 'package:intl/intl.dart'; import '../../model/cex_provider.dart'; import '../../utils/utils.dart'; -import 'dart:developer' as developer; class CandleChart extends StatefulWidget { const CandleChart({ @@ -72,26 +71,6 @@ class CandleChartState extends State @override Widget build(BuildContext context) { - 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 / 500 / widget.candleWidth; - if (staticZoom * scale < minZoom) { - constrained = minZoom / staticZoom; - } - - return constrained; - } - return SizedBox( child: Listener( onPointerDown: (_) { @@ -105,18 +84,7 @@ class CandleChartState extends State }); }, child: GestureDetector( - onHorizontalDragUpdate: (DragUpdateDetails drag) { - if (touchCounter > 1) { - return; - } - - setState(() { - timeAxisShift = _constrainedTimeShift( - timeAxisShift + - drag.delta.dx / scrollDragFactor / staticZoom / dynamicZoom, - ); - }); - }, + onHorizontalDragUpdate: _onDragUpdate, onScaleStart: (_) { setState(() { prevTimeAxisShift = timeAxisShift; @@ -128,25 +96,8 @@ class CandleChartState extends State 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; - }); - }, + onScaleUpdate: _onScaleUpdate, + onTapDown: _onTapDown, onTap: () { tapPosition = tapDownPosition; }, @@ -157,34 +108,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(), @@ -194,6 +118,88 @@ class CandleChartState extends State ), ); } + + 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 / 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 / 500 / widget.candleWidth; + if (staticZoom * scale < minZoom) { + constrained = minZoom / staticZoom; + } + + return constrained; + } } class _ChartPainter extends CustomPainter { @@ -205,10 +211,7 @@ class _ChartPainter extends CustomPainter { this.selectedPoint, this.setWidgetState, }) { - painter = Paint() - ..style = widget.filled ? PaintingStyle.fill : PaintingStyle.stroke - ..strokeWidth = widget.strokeWidth - ..strokeCap = StrokeCap.round; + painter = _initializePaint(); } final CandleChart widget; @@ -216,7 +219,7 @@ class _ChartPainter extends CustomPainter { final double zoom; final Offset tapPosition; final Map selectedPoint; - final Function(String, dynamic) setWidgetState; + final void Function(String, dynamic) setWidgetState; Paint painter; final double pricePaddingPercent = 15; @@ -225,13 +228,12 @@ class _ChartPainter extends CustomPainter { final double marginTop = 14; final double marginBottom = 30; final double labelWidth = 100; - final int visibleCandlesLimit = 100; + final int visibleCandlesLimit = 500; @override void paint(Canvas canvas, Size size) { setWidgetState('canvasSize', size); final double fieldHeight = size.height - marginBottom - marginTop; - final List visibleCandlesData = []; double minPrice = double.infinity; double maxPrice = 0; @@ -250,14 +252,16 @@ class _ChartPainter extends CustomPainter { 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; + setWidgetState( 'maxTimeShift', (widget.data.length - maxVisibleCandles).toDouble(), ); - final double timeAxisMax = firstCandleCloseTime - zoom / timeScaleFactor; - final double timeAxisMin = timeAxisMax - timeRange; // Collect visible candles data + final List visibleCandlesData = []; for (int i = firstCandleIndex; i < lastVisibleCandleIndex; i++) { final CandleData candle = widget.data[i]; final double dx = (candle.closeTime - timeAxisMin) * timeScaleFactor; @@ -292,36 +296,61 @@ class _ChartPainter extends CustomPainter { .round() * priceDivision; - // returns dy for given price + /// Converts a price to a y-coordinate for the candlestick chart. double _price2dy(double price) { - return size.height - - marginBottom - - ((price - originPrice) * priceScaleFactor); + final double relativePrice = price - originPrice; + final double scaledPrice = relativePrice * priceScaleFactor; + return size.height - marginBottom - scaledPrice; } - // returns dx for given time + /// Converts a time to an x-coordinate for the candlestick chart. double _time2dx(int time) { - return (time.toDouble() - timeAxisMin) * timeScaleFactor - - (widget.candleWidth + gap) * zoom / 2; + final double relativeTime = time.toDouble() - timeAxisMin; + final double scaledTime = relativeTime * timeScaleFactor; + final double adjustment = (widget.candleWidth + gap) * zoom / 2; + return scaledTime - adjustment; } - // calculate candles position - final Map candlesToRender = {}; + _drawCandles(visibleCandlesData, _time2dx, _price2dy, canvas); + _drawPriceGrid( + size, + priceDivision, + priceScaleFactor, + originPrice, + _price2dy, + canvas, + ); + _drawCurrentPrice(visibleCandlesData, _price2dy, size, fieldHeight, canvas); + _drawTimeGrid(size, canvas, visibleCandlesData, _time2dx); + + if (tapPosition != null) { + _calculateSelectedPoint(visibleCandlesData, _time2dx, _price2dy); + } + + if (selectedPoint != null) { + _drawSelectedPoint(visibleCandlesData, _time2dx, _price2dy, canvas, size); + } + } + + 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))); + 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 - widget.candleWidth * zoom / 2, @@ -329,14 +358,18 @@ class _ChartPainter extends CustomPainter { top: top, bottom: bottom, ); + _drawCandle(canvas, painter, candlePosition); } + } - // draw candles - candlesToRender.forEach((int timeStamp, CandlePosition candle) { - _drawCandle(canvas, painter, candle); - }); - - // draw price grid + void _drawPriceGrid( + Size size, + double priceDivision, + double priceScaleFactor, + double originPrice, + double Function(double price) _price2dy, + Canvas canvas, + ) { final int visibleDivisions = (size.height / (priceDivision * priceScaleFactor)).floor() + 1; for (int i = 0; i < visibleDivisions; i++) { @@ -360,10 +393,16 @@ class _ChartPainter extends CustomPainter { width: labelWidth, ); } + } - //draw current price - final double currentPrice = - _price(widget.data[firstCandleIndex].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) { @@ -398,8 +437,14 @@ class _ChartPainter extends CustomPainter { align: TextAlign.end, width: 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 - @@ -447,90 +492,98 @@ class _ChartPainter extends CustomPainter { Offset(rightMarkerPosition, size.height - marginBottom + 5), painter, ); + } - // select point on Tap - if (tapPosition != null) { - 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; - } - - 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']); - 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; - } + } + } + 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), painter); - 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 - labelWidth - 2, dy - 2), + width: labelWidth, + ); - _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: labelWidth, - ); + double startY = dy + radius; + while (startY < size.height - 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: labelWidth, + ); } } // paint @@ -615,6 +668,13 @@ class _ChartPainter extends CustomPainter { Offset(point.dx, point.dy - paragraph.height), ); } + + Paint _initializePaint() { + return Paint() + ..style = widget.filled ? PaintingStyle.fill : PaintingStyle.stroke + ..strokeWidth = widget.strokeWidth + ..strokeCap = StrokeCap.round; + } } class CandlePosition { From b0fede24023f693de85e27b65acffbacecb26d7d Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 14 Feb 2024 21:42:42 +0100 Subject: [PATCH 11/17] Fix circular provider updates, limit zoom and refactor --- lib/model/cex_provider.dart | 38 ++- .../markets/build_coin_price_list_item.dart | 263 +++++++++++------- lib/screens/markets/candlestick_chart.dart | 86 +++--- 3 files changed, 225 insertions(+), 162 deletions(-) diff --git a/lib/model/cex_provider.dart b/lib/model/cex_provider.dart index 2c7d4ca69..87f0dcfd8 100644 --- a/lib/model/cex_provider.dart +++ b/lib/model/cex_provider.dart @@ -232,7 +232,7 @@ class CexProvider extends ChangeNotifier { } data[duration] = _durationData; - notifyListeners(); + // notifyListeners(); }); _charts[pair] = ChartData( @@ -243,7 +243,7 @@ class CexProvider extends ChangeNotifier { updated: DateTime.now().millisecondsSinceEpoch, ); - notifyListeners(); + // notifyListeners(); } Future> _fetchChartDataV2(ChainLink link) async { @@ -263,21 +263,24 @@ class CexProvider extends ChangeNotifier { 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], @@ -285,20 +288,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; @@ -312,7 +320,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))) { @@ -332,7 +340,9 @@ class CexProvider extends ChangeNotifier { } } - if (chain != null) return chain; + if (chain != null) { + return chain; + } return null; } @@ -739,7 +749,9 @@ class CexPrices { } void _notifyListeners() { - for (CexProvider provider in _providers) provider.notify(); + for (final CexProvider provider in _providers) { + provider.notify(); + } } } diff --git a/lib/screens/markets/build_coin_price_list_item.dart b/lib/screens/markets/build_coin_price_list_item.dart index e496f4bab..86a2e192b 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,25 @@ 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; + bool coinHasNonZeroPrice = false; + bool coinHasChartData = false; @override Widget build(BuildContext context) { cexProvider = Provider.of(context); - final bool _hasNonzeroPrice = - double.parse(widget.coinBalance.priceForOne ?? '0') > 0; coin = widget.coinBalance.coin; balance = widget.coinBalance.balance; + coinHasNonZeroPrice = + double.parse(widget.coinBalance.priceForOne ?? '0') > 0; _currency = cexProvider.currency.toLowerCase() == 'usd' ? 'USDC' : cexProvider.currency.toUpperCase(); - final bool _hasChartData = cexProvider + coinHasChartData = cexProvider .isChartAvailable('${widget.coinBalance.coin.abbr}-$_currency'); return Column( @@ -63,35 +64,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 +107,7 @@ class _BuildCoinPriceListItemState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ - if (_hasNonzeroPrice) + if (coinHasNonZeroPrice) Row( children: [ CexMarker( @@ -114,25 +118,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 +161,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 +225,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 +233,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 +260,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 +292,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 097f63e4d..1a60371f9 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -30,6 +30,15 @@ class CandleChart extends StatefulWidget { final bool quoted; final Color gridColor; + 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 => 100; + @override CandleChartState createState() => CandleChartState(); } @@ -45,8 +54,7 @@ class CandleChartState extends State double maxTimeShift; Size canvasSize; Offset tapPosition; - Map selectedPoint; // {'timestamp': int, 'price': double} - int scrollDragFactor = 5; + Map selectedPoint; @override void initState() { @@ -134,7 +142,7 @@ class CandleChartState extends State setState(() { final double adjustedDragDelta = - drag.delta.dx / scrollDragFactor / staticZoom / dynamicZoom; + drag.delta.dx / widget.scrollDragFactor / staticZoom / dynamicZoom; timeAxisShift = _constrainedTimeShift( timeAxisShift + adjustedDragDelta, ); @@ -193,7 +201,8 @@ class CandleChartState extends State if (staticZoom * scale > maxZoom) { constrained = maxZoom / staticZoom; } - final double minZoom = canvasSize.width / 500 / widget.candleWidth; + final double minZoom = + canvasSize.width / widget.visibleCandlesLimit / widget.candleWidth; if (staticZoom * scale < minZoom) { constrained = minZoom / staticZoom; } @@ -222,26 +231,21 @@ class _ChartPainter extends CustomPainter { final void Function(String, dynamic) setWidgetState; Paint painter; - final double pricePaddingPercent = 15; - final double pricePreferredDivisions = 4; - final double gap = 2; - final double marginTop = 14; - final double marginBottom = 30; - final double labelWidth = 100; - final int visibleCandlesLimit = 500; @override void paint(Canvas canvas, Size size) { setWidgetState('canvasSize', size); - final double fieldHeight = size.height - marginBottom - marginTop; + final double fieldHeight = + size.height - widget.marginBottom - widget.marginTop; + final List visibleCandlesData = []; double minPrice = double.infinity; double maxPrice = 0; // adjust time axis final int maxVisibleCandles = - (size.width / (widget.candleWidth + gap) / zoom) + (size.width / (widget.candleWidth + widget.gap) / zoom) .floor() - .clamp(0, visibleCandlesLimit); + .clamp(0, widget.visibleCandlesLimit); final int firstCandleIndex = timeAxisShift.floor().clamp(0, widget.data.length - maxVisibleCandles); final int lastVisibleCandleIndex = (firstCandleIndex + maxVisibleCandles) @@ -261,7 +265,6 @@ class _ChartPainter extends CustomPainter { ); // Collect visible candles data - final List visibleCandlesData = []; for (int i = firstCandleIndex; i < lastVisibleCandleIndex; i++) { final CandleData candle = widget.data[i]; final double dx = (candle.closeTime - timeAxisMin) * timeScaleFactor; @@ -287,12 +290,13 @@ class _ChartPainter extends CustomPainter { // adjust price axis final double priceRange = maxPrice - minPrice; final double priceAxis = - priceRange + (2 * priceRange * pricePaddingPercent / 100); + priceRange + (2 * priceRange * widget.pricePaddingPercent / 100); final double priceScaleFactor = fieldHeight / priceAxis; final double priceDivision = - _priceDivision(priceAxis, pricePreferredDivisions); + _priceDivision(priceAxis, widget.pricePreferredDivisions); final double originPrice = - ((minPrice - (priceRange * pricePaddingPercent / 100)) / priceDivision) + ((minPrice - (priceRange * widget.pricePaddingPercent / 100)) / + priceDivision) .round() * priceDivision; @@ -300,14 +304,14 @@ class _ChartPainter extends CustomPainter { double _price2dy(double price) { final double relativePrice = price - originPrice; final double scaledPrice = relativePrice * priceScaleFactor; - return size.height - marginBottom - scaledPrice; + 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() - timeAxisMin; final double scaledTime = relativeTime * timeScaleFactor; - final double adjustment = (widget.candleWidth + gap) * zoom / 2; + final double adjustment = (widget.candleWidth + widget.gap) * zoom / 2; return scaledTime - adjustment; } @@ -390,7 +394,7 @@ class _ChartPainter extends CustomPainter { text: formattedPrice, color: widget.textColor, align: TextAlign.start, - width: labelWidth, + width: widget.labelWidth, ); } } @@ -405,13 +409,13 @@ class _ChartPainter extends CustomPainter { 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) @@ -430,12 +434,12 @@ class _ChartPainter extends CustomPainter { _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, ); } @@ -448,18 +452,18 @@ class _ChartPainter extends CustomPainter { double rightMarkerPosition = size.width; if (timeAxisShift < 0) { rightMarkerPosition = rightMarkerPosition - - (widget.candleWidth / 2 + gap / 2 - timeAxisShift) * zoom; + (widget.candleWidth / 2 + widget.gap / 2 - timeAxisShift) * zoom; } _drawText( canvas: canvas, color: widget.textColor, point: Offset( - rightMarkerPosition - labelWidth - 4, + rightMarkerPosition - widget.labelWidth - 4, size.height - 7, ), text: _formatTime(visibleCandlesData.first.closeTime), align: TextAlign.end, - width: labelWidth, + width: widget.labelWidth, ); _drawText( canvas: canvas, @@ -470,26 +474,26 @@ class _ChartPainter extends CustomPainter { ), text: _formatTime(visibleCandlesData.last.closeTime), align: TextAlign.start, - width: labelWidth, + width: widget.labelWidth, ); 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), + Offset(dx, size.height - widget.marginBottom), + Offset(dx, size.height - widget.marginBottom + 5), painter, ); } painter.color = widget.textColor; canvas.drawLine( - Offset(0, size.height - marginBottom), - Offset(0, size.height - marginBottom + 5), + Offset(0, size.height - widget.marginBottom), + Offset(0, size.height - widget.marginBottom + 5), painter, ); canvas.drawLine( - Offset(rightMarkerPosition, size.height - marginBottom), - Offset(rightMarkerPosition, size.height - marginBottom + 5), + Offset(rightMarkerPosition, size.height - widget.marginBottom), + Offset(rightMarkerPosition, size.height - widget.marginBottom + 5), painter, ); } @@ -565,12 +569,12 @@ class _ChartPainter extends CustomPainter { 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, + point: Offset(size.width - widget.labelWidth - 2, dy - 2), + width: widget.labelWidth, ); double startY = dy + radius; - while (startY < size.height - marginBottom + 10) { + while (startY < size.height - widget.marginBottom + 10) { canvas.drawLine(Offset(dx, startY), Offset(dx, startY + 5), painter); startY += 10; } @@ -582,7 +586,7 @@ class _ChartPainter extends CustomPainter { backgroundColor: widget.textColor, text: ' ${_formatTime(selectedCandle.closeTime)} ', point: Offset(dx - 50, size.height - 7), - width: labelWidth, + width: widget.labelWidth, ); } } // paint From 0941141a465aa7276b68a836b044abcbc9f0f80f Mon Sep 17 00:00:00 2001 From: Francois Date: Thu, 15 Feb 2024 15:02:37 +0100 Subject: [PATCH 12/17] Reduce rebuilds of `BuildCoinPriceListItem` - Remove touch count listener on `CoinsPriceList` widget - Change state variables to local variables in the `build` function - Refactor `CandleChart` to make CPU Flame Chart clearer and code more readable --- .../markets/build_coin_price_list_item.dart | 11 +- lib/screens/markets/candlestick_chart.dart | 232 +++++++++++++----- lib/screens/markets/coins_price_list.dart | 47 ++-- 3 files changed, 184 insertions(+), 106 deletions(-) diff --git a/lib/screens/markets/build_coin_price_list_item.dart b/lib/screens/markets/build_coin_price_list_item.dart index 86a2e192b..c72c7eaa0 100644 --- a/lib/screens/markets/build_coin_price_list_item.dart +++ b/lib/screens/markets/build_coin_price_list_item.dart @@ -31,22 +31,19 @@ class _BuildCoinPriceListItemState extends State { bool quotedChart = false; String chartDuration = '3600'; CexProvider cexProvider; - String _currency; - bool coinHasNonZeroPrice = false; - bool coinHasChartData = false; @override Widget build(BuildContext context) { - cexProvider = Provider.of(context); + cexProvider = Provider.of(context, listen: false); coin = widget.coinBalance.coin; balance = widget.coinBalance.balance; - coinHasNonZeroPrice = + final bool coinHasNonZeroPrice = double.parse(widget.coinBalance.priceForOne ?? '0') > 0; - _currency = cexProvider.currency.toLowerCase() == 'usd' + final String _currency = cexProvider.currency.toLowerCase() == 'usd' ? 'USDC' : cexProvider.currency.toUpperCase(); - coinHasChartData = cexProvider + final bool coinHasChartData = cexProvider .isChartAvailable('${widget.coinBalance.coin.abbr}-$_currency'); return Column( diff --git a/lib/screens/markets/candlestick_chart.dart b/lib/screens/markets/candlestick_chart.dart index 1a60371f9..65a9b9eda 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -30,6 +30,8 @@ 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; @@ -37,7 +39,7 @@ class CandleChart extends StatefulWidget { double get marginTop => 14; double get marginBottom => 30; double get labelWidth => 100; - int get visibleCandlesLimit => 100; + int get visibleCandlesLimit => 500; @override CandleChartState createState() => CandleChartState(); @@ -93,22 +95,11 @@ class CandleChartState extends State }, child: GestureDetector( onHorizontalDragUpdate: _onDragUpdate, - onScaleStart: (_) { - setState(() { - prevTimeAxisShift = timeAxisShift; - }); - }, - onScaleEnd: (_) { - setState(() { - staticZoom = staticZoom * dynamicZoom; - dynamicZoom = 1; - }); - }, + onScaleStart: _onScaleStart, + onScaleEnd: _onScaleEnd, onScaleUpdate: _onScaleUpdate, onTapDown: _onTapDown, - onTap: () { - tapPosition = tapDownPosition; - }, + onTap: _onTap, child: CustomPaint( painter: _ChartPainter( widget: widget, @@ -127,6 +118,23 @@ 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; @@ -237,9 +245,6 @@ class _ChartPainter extends CustomPainter { setWidgetState('canvasSize', size); final double fieldHeight = size.height - widget.marginBottom - widget.marginTop; - final List visibleCandlesData = []; - double minPrice = double.infinity; - double maxPrice = 0; // adjust time axis final int maxVisibleCandles = @@ -251,6 +256,63 @@ class _ChartPainter extends CustomPainter { final int lastVisibleCandleIndex = (firstCandleIndex + maxVisibleCandles) .clamp(maxVisibleCandles - 1, widget.data.length - 1); + setWidgetState( + 'maxTimeShift', + (widget.data.length - maxVisibleCandles).toDouble(), + ); + + final VisibleCandles visibleCandles = _collectVisibleCandles( + firstCandleIndex, + lastVisibleCandleIndex, + 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 firstCandleIndex, + int lastVisibleCandleIndex, + Size size, + double fieldHeight, + ) { final int firstCandleCloseTime = widget.data[firstCandleIndex].closeTime; final int lastCandleCloseTime = widget.data[lastVisibleCandleIndex].closeTime; @@ -259,12 +321,9 @@ class _ChartPainter extends CustomPainter { final double timeAxisMax = firstCandleCloseTime - zoom / timeScaleFactor; final double timeAxisMin = timeAxisMax - timeRange; - setWidgetState( - 'maxTimeShift', - (widget.data.length - maxVisibleCandles).toDouble(), - ); - - // Collect visible candles data + 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; @@ -287,53 +346,60 @@ class _ChartPainter extends CustomPainter { visibleCandlesData.add(candle); } - // adjust price axis + 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 * widget.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, widget.pricePreferredDivisions); - final double originPrice = - ((minPrice - (priceRange * widget.pricePaddingPercent / 100)) / - priceDivision) - .round() * - priceDivision; - - /// Converts a price to a y-coordinate for the candlestick chart. - double _price2dy(double price) { - final double relativePrice = price - originPrice; - final double scaledPrice = relativePrice * 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() - timeAxisMin; - final double scaledTime = relativeTime * timeScaleFactor; - final double adjustment = (widget.candleWidth + widget.gap) * zoom / 2; - return scaledTime - adjustment; - } - _drawCandles(visibleCandlesData, _time2dx, _price2dy, canvas); - _drawPriceGrid( - size, + // Calculate the origin price + final double originPrice = _calculateOriginPrice( + minPrice, + priceRange, priceDivision, - priceScaleFactor, - originPrice, - _price2dy, - canvas, ); - _drawCurrentPrice(visibleCandlesData, _price2dy, size, fieldHeight, canvas); - _drawTimeGrid(size, canvas, visibleCandlesData, _time2dx); - if (tapPosition != null) { - _calculateSelectedPoint(visibleCandlesData, _time2dx, _price2dy); - } + return CandleGridData( + originPrice: originPrice, + priceScaleFactor: priceScaleFactor, + priceDivision: priceDivision, + timeAxisMax: timeAxisMax, + timeAxisMin: timeAxisMin, + timeScaleFactor: timeScaleFactor, + ); + } - if (selectedPoint != null) { - _drawSelectedPoint(visibleCandlesData, _time2dx, _price2dy, canvas, size); - } + 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; } void _drawCandles( @@ -368,17 +434,17 @@ class _ChartPainter extends CustomPainter { void _drawPriceGrid( Size size, - double priceDivision, - double priceScaleFactor, - double originPrice, + CandleGridData gridData, double Function(double price) _price2dy, Canvas canvas, ) { - final int visibleDivisions = - (size.height / (priceDivision * priceScaleFactor)).floor() + 1; + 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++) { painter.color = widget.gridColor; - final double price = originPrice + i * priceDivision; + final double price = gridData.originPrice + i * gridData.priceDivision; final double dy = _price2dy(price); canvas.drawLine(Offset(0, dy), Offset(size.width, dy), painter); final String formattedPrice = formatPrice(price, 8); @@ -700,3 +766,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..88dd72cf7 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,36 +39,21 @@ class _CoinsPriceListState extends State { ? Center( child: Text( AppLocalizations.of(context).noCoinFound, - style: TextStyle(fontSize: 18), + style: const TextStyle(fontSize: 18), ), ) - : Listener( - onPointerDown: (_) { - setState(() { - touchCounter++; - }); - }, - onPointerUp: (_) { - setState(() { - touchCounter--; - }); + : ListView.builder( + 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), + ); }, - 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); - }, - ); - }), ); } else { return const Center( From 5d1fe0c366d40e9f535b9fa0199912f2be44c11b Mon Sep 17 00:00:00 2001 From: Francois Date: Thu, 15 Feb 2024 16:35:25 +0100 Subject: [PATCH 13/17] Remove `Listener` in `CandleChart` Improve scroll and zoom responsiveness --- lib/screens/markets/candlestick_chart.dart | 59 +++++++++------------- 1 file changed, 23 insertions(+), 36 deletions(-) diff --git a/lib/screens/markets/candlestick_chart.dart b/lib/screens/markets/candlestick_chart.dart index 65a9b9eda..349c92d24 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -52,7 +52,6 @@ class CandleChartState extends State double dynamicZoom; double staticZoom; Offset tapDownPosition; - int touchCounter = 0; double maxTimeShift; Size canvasSize; Offset tapPosition; @@ -82,36 +81,24 @@ class CandleChartState extends State @override Widget build(BuildContext context) { return SizedBox( - child: Listener( - onPointerDown: (_) { - setState(() { - touchCounter++; - }); - }, - onPointerUp: (_) { - setState(() { - touchCounter--; - }); - }, - child: GestureDetector( - onHorizontalDragUpdate: _onDragUpdate, - onScaleStart: _onScaleStart, - onScaleEnd: _onScaleEnd, - onScaleUpdate: _onScaleUpdate, - onTapDown: _onTapDown, - onTap: _onTap, - child: CustomPaint( - painter: _ChartPainter( - widget: widget, - timeAxisShift: timeAxisShift, - zoom: staticZoom * dynamicZoom, - tapPosition: tapPosition, - selectedPoint: selectedPoint, - setWidgetState: _painterStateCallback, - ), - child: Center( - child: Container(), - ), + child: GestureDetector( + onHorizontalDragUpdate: _onDragUpdate, + onScaleStart: _onScaleStart, + onScaleEnd: _onScaleEnd, + onScaleUpdate: _onScaleUpdate, + onTapDown: _onTapDown, + onTap: _onTap, + child: CustomPaint( + painter: _ChartPainter( + widget: widget, + timeAxisShift: timeAxisShift, + zoom: staticZoom * dynamicZoom, + tapPosition: tapPosition, + selectedPoint: selectedPoint, + setWidgetState: _painterStateCallback, + ), + child: Center( + child: Container(), ), ), ), @@ -144,10 +131,6 @@ class CandleChartState extends State } void _onDragUpdate(DragUpdateDetails drag) { - if (touchCounter > 1) { - return; - } - setState(() { final double adjustedDragDelta = drag.delta.dx / widget.scrollDragFactor / staticZoom / dynamicZoom; @@ -247,10 +230,14 @@ class _ChartPainter extends CustomPainter { size.height - widget.marginBottom - widget.marginTop; // adjust time axis + final int adjustedVisibleCandleLimit = + widget.visibleCandlesLimit > widget.data.length + ? widget.data.length + : widget.visibleCandlesLimit; final int maxVisibleCandles = (size.width / (widget.candleWidth + widget.gap) / zoom) .floor() - .clamp(0, widget.visibleCandlesLimit); + .clamp(0, adjustedVisibleCandleLimit); final int firstCandleIndex = timeAxisShift.floor().clamp(0, widget.data.length - maxVisibleCandles); final int lastVisibleCandleIndex = (firstCandleIndex + maxVisibleCandles) From b03df8969e08213016b0daf4b3164146cad7fee0 Mon Sep 17 00:00:00 2001 From: Francois Date: Thu, 15 Feb 2024 17:42:19 +0100 Subject: [PATCH 14/17] Limit tickers to actively traded symbols on Binance Fixes BNB-USDC graph bug using older data --- lib/model/cex_provider.dart | 16 +++----- .../bloc/binance_repository.dart | 28 +++++++------- lib/screens/markets/coins_price_list.dart | 37 +++++++++++++------ 3 files changed, 45 insertions(+), 36 deletions(-) diff --git a/lib/model/cex_provider.dart b/lib/model/cex_provider.dart index 87f0dcfd8..cfdee7f07 100644 --- a/lib/model/cex_provider.dart +++ b/lib/model/cex_provider.dart @@ -88,9 +88,7 @@ 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(); @@ -98,7 +96,9 @@ class CexProvider extends ChangeNotifier { void _updateRates() => cexPrices.updateRates(); List _getTickers() { - if (_tickers != null) return _tickers; + if (_tickers != null) { + return _tickers; + } _updateTickersListV2(); return _tickersFallBack; @@ -106,8 +106,7 @@ class CexProvider extends ChangeNotifier { Future _updateTickersListV2() async { try { - final List tickers = await _binanceRepository.getLegacyTickers(); - _tickers = tickers; + _tickers = await _binanceRepository.getLegacyTickers(); notifyListeners(); } catch (e) { Log('cex_provider', 'Failed to fetch tickers list: $e'); @@ -162,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(); @@ -232,7 +231,6 @@ class CexProvider extends ChangeNotifier { } data[duration] = _durationData; - // notifyListeners(); }); _charts[pair] = ChartData( @@ -242,8 +240,6 @@ class CexProvider extends ChangeNotifier { status: ChartStatus.success, updated: DateTime.now().millisecondsSinceEpoch, ); - - // notifyListeners(); } Future> _fetchChartDataV2(ChainLink link) async { diff --git a/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart index 99eb90da1..a2542e5fe 100644 --- a/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart +++ b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart @@ -33,8 +33,6 @@ class BinanceRepository { final BinanceProvider _binanceProvider; - List _symbols = []; - /// 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. @@ -43,23 +41,22 @@ class BinanceRepository { /// /// Returns a list of tickers. Future> getLegacyTickers() async { - if (_symbols.isNotEmpty) { - return _symbols; - } - final BinanceExchangeInfoResponse exchangeInfo = await _binanceProvider.fetchExchangeInfo(); - if (exchangeInfo != null) { - // The legacy candlestick implementation uses hyphenated lowercase - // symbols, so we convert the symbols to that format here. - _symbols = exchangeInfo.symbols - .map( - (Symbol symbol) => - '${symbol.baseAsset}-${symbol.quoteAsset}'.toLowerCase(), - ) - .toList(); + 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; } @@ -112,6 +109,7 @@ class BinanceRepository { (BinanceKline a, BinanceKline b) => b.closeTime.compareTo(a.closeTime), ); + ohlcData[duration] = klinesResponse.klines .map((BinanceKline kline) => kline.toMap()) .toList(); diff --git a/lib/screens/markets/coins_price_list.dart b/lib/screens/markets/coins_price_list.dart index 88dd72cf7..5c903e24e 100644 --- a/lib/screens/markets/coins_price_list.dart +++ b/lib/screens/markets/coins_price_list.dart @@ -42,18 +42,33 @@ class _CoinsPriceListState extends State { style: const TextStyle(fontSize: 18), ), ) - : ListView.builder( - 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), - ); + : Listener( + onPointerDown: (_) { + setState(() { + touchCounter++; + }); }, + onPointerUp: (_) { + setState(() { + touchCounter--; + }); + }, + child: ListView.builder( + 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( From c0202217f55ae2fa474c337b90925087357e76d8 Mon Sep 17 00:00:00 2001 From: Francois Date: Thu, 15 Feb 2024 18:13:20 +0100 Subject: [PATCH 15/17] Revert: Remove `Listener` in `CandleChart` --- lib/screens/markets/candlestick_chart.dart | 53 ++++++++++++++-------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/lib/screens/markets/candlestick_chart.dart b/lib/screens/markets/candlestick_chart.dart index 349c92d24..76ad9c448 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -52,6 +52,7 @@ class CandleChartState extends State double dynamicZoom; double staticZoom; Offset tapDownPosition; + int touchCounter = 0; double maxTimeShift; Size canvasSize; Offset tapPosition; @@ -81,24 +82,36 @@ class CandleChartState extends State @override Widget build(BuildContext context) { return SizedBox( - child: GestureDetector( - onHorizontalDragUpdate: _onDragUpdate, - onScaleStart: _onScaleStart, - onScaleEnd: _onScaleEnd, - onScaleUpdate: _onScaleUpdate, - onTapDown: _onTapDown, - onTap: _onTap, - child: CustomPaint( - painter: _ChartPainter( - widget: widget, - timeAxisShift: timeAxisShift, - zoom: staticZoom * dynamicZoom, - tapPosition: tapPosition, - selectedPoint: selectedPoint, - setWidgetState: _painterStateCallback, - ), - child: Center( - child: Container(), + child: Listener( + onPointerDown: (_) { + setState(() { + touchCounter++; + }); + }, + onPointerUp: (_) { + setState(() { + touchCounter--; + }); + }, + child: GestureDetector( + onHorizontalDragUpdate: _onDragUpdate, + onScaleStart: _onScaleStart, + onScaleEnd: _onScaleEnd, + onScaleUpdate: _onScaleUpdate, + onTapDown: _onTapDown, + onTap: _onTap, + child: CustomPaint( + painter: _ChartPainter( + widget: widget, + timeAxisShift: timeAxisShift, + zoom: staticZoom * dynamicZoom, + tapPosition: tapPosition, + selectedPoint: selectedPoint, + setWidgetState: _painterStateCallback, + ), + child: Center( + child: Container(), + ), ), ), ), @@ -131,6 +144,10 @@ class CandleChartState extends State } void _onDragUpdate(DragUpdateDetails drag) { + if (touchCounter > 1) { + return; + } + setState(() { final double adjustedDragDelta = drag.delta.dx / widget.scrollDragFactor / staticZoom / dynamicZoom; From a295d2cd83ada31e909f504c3ce2b28b04b90ed2 Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 16 Feb 2024 10:55:14 +0100 Subject: [PATCH 16/17] Update comments & paint method cleanup cont. --- lib/model/cex_provider.dart | 12 +++++----- .../bloc/binance_provider.dart | 6 ++--- .../bloc/binance_repository.dart | 7 +++--- lib/screens/markets/candlestick_chart.dart | 24 +++++++------------ 4 files changed, 21 insertions(+), 28 deletions(-) diff --git a/lib/model/cex_provider.dart b/lib/model/cex_provider.dart index cfdee7f07..ddbe8aed6 100644 --- a/lib/model/cex_provider.dart +++ b/lib/model/cex_provider.dart @@ -18,7 +18,7 @@ import '../utils/utils.dart'; class CexProvider extends ChangeNotifier { CexProvider() { - _updateTickersListV2(); + _updateTickersList(); _updateRates(); cexPrices.linkProvider(this); @@ -100,11 +100,11 @@ class CexProvider extends ChangeNotifier { return _tickers; } - _updateTickersListV2(); + _updateTickersList(); return _tickersFallBack; } - Future _updateTickersListV2() async { + Future _updateTickersList() async { try { _tickers = await _binanceRepository.getLegacyTickers(); notifyListeners(); @@ -129,9 +129,9 @@ class CexProvider extends ChangeNotifier { _charts[pair].status = ChartStatus.fetching; } try { - json0 = await _fetchChartDataV2(chain[0]); + json0 = await _fetchChartData(chain[0]); if (chain.length > 1) { - json1 = await _fetchChartDataV2(chain[1]); + json1 = await _fetchChartData(chain[1]); } } catch (_) { _updatingChart = false; @@ -242,7 +242,7 @@ class CexProvider extends ChangeNotifier { ); } - Future> _fetchChartDataV2(ChainLink link) async { + Future> _fetchChartData(ChainLink link) async { try { final String pair = '${link.rel}-${link.base}'; final Map result = diff --git a/lib/packages/binance_candlestick_charts/bloc/binance_provider.dart b/lib/packages/binance_candlestick_charts/bloc/binance_provider.dart index 0058a5099..40f6ba5b9 100644 --- a/lib/packages/binance_candlestick_charts/bloc/binance_provider.dart +++ b/lib/packages/binance_candlestick_charts/bloc/binance_provider.dart @@ -20,9 +20,9 @@ class BinanceProvider { /// 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, Unix time) of the data range to fetch (optional). - /// - [endTime]: The end time (in milliseconds, Unix time) of the data range to fetch (optional). - /// - [limit]: The maximum number of data points to fetch (optional). + /// - [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. diff --git a/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart index a2542e5fe..67858c637 100644 --- a/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart +++ b/lib/packages/binance_candlestick_charts/bloc/binance_repository.dart @@ -68,14 +68,14 @@ class BinanceRepository { /// 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 250. + /// - 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('BTCUSDT', ohlcDurations: ['1m', '5m', '1h']); + /// final Map candleData = await getLegacyOhlcCandleData('btc-usdt', ohlcDurations: ['1m', '5m', '1h']); /// ``` Future> getLegacyOhlcCandleData( String symbol, { @@ -98,8 +98,7 @@ class BinanceRepository { await _binanceProvider.fetchKlines( symbol, defaultCandleIntervalsBinanceMap[duration], - limit: - limit, // The default is 500, and the max is 1000 for Binance. + limit: limit, ); if (klinesResponse != null) { diff --git a/lib/screens/markets/candlestick_chart.dart b/lib/screens/markets/candlestick_chart.dart index 76ad9c448..1c02f85cc 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -40,6 +40,8 @@ class CandleChart extends StatefulWidget { 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(); @@ -245,20 +247,10 @@ class _ChartPainter extends CustomPainter { setWidgetState('canvasSize', size); final double fieldHeight = size.height - widget.marginBottom - widget.marginTop; - - // adjust time axis - final int adjustedVisibleCandleLimit = - widget.visibleCandlesLimit > widget.data.length - ? widget.data.length - : widget.visibleCandlesLimit; final int maxVisibleCandles = (size.width / (widget.candleWidth + widget.gap) / zoom) .floor() - .clamp(0, adjustedVisibleCandleLimit); - final int firstCandleIndex = - timeAxisShift.floor().clamp(0, widget.data.length - maxVisibleCandles); - final int lastVisibleCandleIndex = (firstCandleIndex + maxVisibleCandles) - .clamp(maxVisibleCandles - 1, widget.data.length - 1); + .clamp(0, widget.adjustedVisibleCandleLimit); setWidgetState( 'maxTimeShift', @@ -266,8 +258,7 @@ class _ChartPainter extends CustomPainter { ); final VisibleCandles visibleCandles = _collectVisibleCandles( - firstCandleIndex, - lastVisibleCandleIndex, + maxVisibleCandles, size, fieldHeight, ); @@ -312,11 +303,14 @@ class _ChartPainter extends CustomPainter { } VisibleCandles _collectVisibleCandles( - int firstCandleIndex, - int lastVisibleCandleIndex, + 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; From 0b3d3c876c97729f4b0f6a11b276fec2784604f4 Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 16 Feb 2024 10:59:13 +0100 Subject: [PATCH 17/17] Display time axis in local time --- lib/screens/markets/candlestick_chart.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/markets/candlestick_chart.dart b/lib/screens/markets/candlestick_chart.dart index 1c02f85cc..da41a468a 100644 --- a/lib/screens/markets/candlestick_chart.dart +++ b/lib/screens/markets/candlestick_chart.dart @@ -707,7 +707,7 @@ class _ChartPainter extends CustomPainter { ); const String format = 'MMM dd yyyy HH:mm'; - return DateFormat(format).format(utc); + return DateFormat(format).format(utc.toLocal()); } void _drawText({