mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-26 06:08:40 +01:00
charts (#3162)
* charts (wip) * charts (wip) * wip * add asset grid * split files * logic wip * proper getPrices logic * viewmodel (wip) * charts * remove unused import * remove restricted import...? * fix background gradient when scrolling * add safeguards for broken db * save new pin on removal * add safeguards for concurrency * add haptic feedback * fix haptic feedback * fix fiat ticker * reduce chart padding * add line touch indicator * improve touch target size on range selector * add date/time display when viewing past amount * merge * remove iconSvgPath reference * merge * fix state for date display * merge * auto-reformat * only load charts when page is opened * merge * reformat and apply lints * refactor to use `Money` instead of storing amount in `String` * add slop comment * fix exception on division by zero * ci * workaround toDouble being removed * disable strict parsing and rounding * fix abs() * use new currency picker sheet * safeguard for max flutter-supported decimals * fix amount precision * adjust to new buy/sell * Update lib/new-ui/pages/charts_page.dart Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com> * adjust buy/sell di * replace -> ignore * reformat --------- Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
+18
-4
@@ -52,6 +52,7 @@ import 'package:cake_wallet/exchange/trade.dart';
|
||||
import 'package:cake_wallet/haven/cw_haven.dart';
|
||||
import 'package:cake_wallet/monero/monero.dart';
|
||||
import 'package:cake_wallet/nano/nano.dart';
|
||||
import 'package:cake_wallet/new-ui/model/charts/price_store.dart';
|
||||
import 'package:cake_wallet/new-ui/new_dashboard.dart';
|
||||
import 'package:cake_wallet/new-ui/pages/about_page.dart';
|
||||
import 'package:cake_wallet/new-ui/pages/account_customizer.dart';
|
||||
@@ -59,6 +60,9 @@ import 'package:cake_wallet/new-ui/pages/bridge/bridge_amount_page.dart';
|
||||
import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart';
|
||||
import 'package:cake_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart';
|
||||
import 'package:cake_wallet/new-ui/pages/buy_sell/buy_sell_amount_page.dart';
|
||||
import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart';
|
||||
import 'package:cake_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart';
|
||||
import 'package:cake_wallet/new-ui/pages/charts_page.dart';
|
||||
import 'package:cake_wallet/new-ui/pages/coin_control_page.dart';
|
||||
import 'package:cake_wallet/new-ui/pages/addresses_page.dart';
|
||||
import 'package:cake_wallet/new-ui/pages/home_page.dart';
|
||||
@@ -68,6 +72,7 @@ import 'package:cake_wallet/new-ui/pages/lightning_username_page.dart';
|
||||
import 'package:cake_wallet/new-ui/pages/receive_page.dart';
|
||||
import "package:cake_wallet/new-ui/pages/seed/pre_seed_page.dart";
|
||||
import "package:cake_wallet/new-ui/pages/seed/show_keys_disclaimer_page.dart";
|
||||
import 'package:cake_wallet/new-ui/viewmodels/charts/charts_bloc.dart';
|
||||
import 'package:cake_wallet/new-ui/viewmodels/lightning_username/lightning_username_bloc.dart';
|
||||
import 'package:cake_wallet/new-ui/widgets/addresses_page/address_label_input.dart';
|
||||
import 'package:cake_wallet/new-ui/widgets/buy_sell/buy_sell_selector_modal.dart';
|
||||
@@ -552,6 +557,15 @@ Future<void> setup({
|
||||
(displayMode == BitcoinAmountDisplayMode.satoshiForLightning && lightningMode)));
|
||||
});
|
||||
|
||||
getIt.registerLazySingleton<PriceStore>(() => PriceStore());
|
||||
|
||||
getIt.registerFactory<ChartsBloc>(
|
||||
() => ChartsBloc(appStore: getIt.get<AppStore>(), priceStore: getIt.get<PriceStore>()));
|
||||
|
||||
getIt.registerFactory<ChartsPage>(() => ChartsPage(
|
||||
chartsBloc: getIt.get<ChartsBloc>(),
|
||||
));
|
||||
|
||||
getIt.registerFactory<AccountCreationModal>(() => AccountCreationModal(
|
||||
accountEditOrCreateViewModel: getIt.get<MoneroAccountEditOrCreateViewModel>()));
|
||||
|
||||
@@ -1321,11 +1335,11 @@ Future<void> setup({
|
||||
|
||||
getIt.registerFactory(() => BuyAmountViewModel());
|
||||
|
||||
getIt.registerFactoryParam<BuySellViewModel, BuySellPageMode, void>((mode, _) => BuySellViewModel(
|
||||
mode: mode, getIt.get<AppStore>(), fiatConversionStore: getIt.get<FiatConversionStore>()));
|
||||
getIt.registerFactoryParam<BuySellViewModel, NewBuySellParams, void>((params, _) => BuySellViewModel(
|
||||
params: params, getIt.get<AppStore>(), fiatConversionStore: getIt.get<FiatConversionStore>()));
|
||||
|
||||
getIt.registerFactoryParam<NewBuySellAmountPage, BuySellPageMode, void>((mode, _) =>
|
||||
NewBuySellAmountPage(buySellViewModel: getIt.get<BuySellViewModel>(param1: mode)));
|
||||
getIt.registerFactoryParam<NewBuySellAmountPage, NewBuySellParams, void>((params, _) =>
|
||||
NewBuySellAmountPage(buySellViewModel: getIt.get<BuySellViewModel>(param1: params)));
|
||||
|
||||
|
||||
getIt.registerFactoryParam<OrderDetailsViewModel, Order, void>((order, _) {
|
||||
|
||||
@@ -13,8 +13,10 @@ import 'package:cake_wallet/entities/haven_seed_store.dart';
|
||||
import 'package:cake_wallet/entities/preferences_key.dart';
|
||||
import 'package:cake_wallet/entities/secret_store_key.dart';
|
||||
import 'package:cake_wallet/monero/monero.dart';
|
||||
import 'package:cake_wallet/new-ui/model/charts/charts_asset.dart';
|
||||
import 'package:cake_wallet/wownero/wownero.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:cw_core/crypto_currency.dart';
|
||||
import 'package:cw_core/node.dart';
|
||||
import 'package:cake_wallet/entities/sync_status_display_mode.dart';
|
||||
import 'package:cw_core/node_list.dart';
|
||||
@@ -648,6 +650,9 @@ Future<void> defaultSettingsMigration(
|
||||
enabled: false,
|
||||
);
|
||||
break;
|
||||
case 72:
|
||||
await createDefaultChartsData();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -1338,6 +1343,12 @@ Future<void> _addXaut0TokenToExistingSolanaWallets() async {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createDefaultChartsData() async {
|
||||
await ChartsAsset(asset: CryptoCurrency.btc, isFavorite: true).insert();
|
||||
await ChartsAsset(asset: CryptoCurrency.xmr, isFavorite: false).insert();
|
||||
await ChartsAsset(asset: CryptoCurrency.eth, isFavorite: false).insert();
|
||||
}
|
||||
|
||||
Future<void> _addTbbTokenToExistingSolanaWallets() async {
|
||||
try {
|
||||
final tbbToken = SPLToken(
|
||||
|
||||
@@ -19,6 +19,9 @@ class FiatCurrency extends EnumerableItem<String> with Serializable<String> impl
|
||||
@override
|
||||
final int decimals;
|
||||
|
||||
@override
|
||||
String get apiString => "fiat.$title";
|
||||
|
||||
@override
|
||||
final String symbol;
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ class MainActions {
|
||||
canShow: (viewModel) => viewModel.hasTradeAction,
|
||||
onTap: (BuildContext context, DashboardViewModel viewModel) async {
|
||||
if (!viewModel.isEnabledTradeAction) return;
|
||||
await Navigator.of(context).pushNamed(Routes.buySellPage, arguments: false);
|
||||
await Navigator.of(context).pushNamed(Routes.buySellPage);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import 'package:cake_wallet/generated/i18n.dart';
|
||||
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:cake_wallet/generated/i18n.dart";
|
||||
import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart";
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
class NewMainActions {
|
||||
final Key key;
|
||||
final String Function(BuildContext context) name;
|
||||
final String image;
|
||||
final VoidCallback onTap;
|
||||
final bool Function(DashboardViewModel viewModel)? isEnabled;
|
||||
final bool Function(DashboardViewModel viewModel)? canShow;
|
||||
|
||||
NewMainActions._({
|
||||
required this.key,
|
||||
@@ -18,48 +12,54 @@ class NewMainActions {
|
||||
this.isEnabled,
|
||||
this.canShow,
|
||||
});
|
||||
final Key key;
|
||||
final String Function(BuildContext context) name;
|
||||
final String image;
|
||||
final VoidCallback onTap;
|
||||
final bool Function(DashboardViewModel viewModel)? isEnabled;
|
||||
final bool Function(DashboardViewModel viewModel)? canShow;
|
||||
|
||||
static List<NewMainActions> all = [
|
||||
homeAction,
|
||||
walletsAction,
|
||||
contactsAction,
|
||||
appsAction,
|
||||
//chartsAction,
|
||||
chartsAction,
|
||||
];
|
||||
|
||||
static NewMainActions homeAction = NewMainActions._(
|
||||
name: (context) => S.of(context).home,
|
||||
image: 'assets/new-ui/navbar/home.svg',
|
||||
key: ValueKey('dashboard_page_home_action_button_key'),
|
||||
image: "assets/new-ui/navbar/home.svg",
|
||||
key: const ValueKey("dashboard_page_home_action_button_key"),
|
||||
onTap: () {},
|
||||
);
|
||||
|
||||
static NewMainActions walletsAction = NewMainActions._(
|
||||
name: (context) => S.of(context).wallets,
|
||||
image: 'assets/new-ui/navbar/wallets.svg',
|
||||
key: ValueKey('dashboard_page_wallets_action_button_key'),
|
||||
image: "assets/new-ui/navbar/wallets.svg",
|
||||
key: const ValueKey("dashboard_page_wallets_action_button_key"),
|
||||
onTap: () {},
|
||||
);
|
||||
|
||||
static NewMainActions contactsAction = NewMainActions._(
|
||||
name: (context) => S.of(context).contacts,
|
||||
image: 'assets/new-ui/navbar/contacts.svg',
|
||||
key: ValueKey('dashboard_page_contacts_action_button_key'),
|
||||
image: "assets/new-ui/navbar/contacts.svg",
|
||||
key: const ValueKey("dashboard_page_contacts_action_button_key"),
|
||||
onTap: () {},
|
||||
);
|
||||
|
||||
static NewMainActions appsAction = NewMainActions._(
|
||||
name: (context) => S.of(context).apps,
|
||||
image: 'assets/new-ui/navbar/apps.svg',
|
||||
key: ValueKey('dashboard_page_apps_action_button_key'),
|
||||
image: "assets/new-ui/navbar/apps.svg",
|
||||
key: const ValueKey("dashboard_page_apps_action_button_key"),
|
||||
canShow: (dashboardVM) => dashboardVM.showApps,
|
||||
onTap: () {},
|
||||
);
|
||||
|
||||
// static NewMainActions chartsAction = NewMainActions._(
|
||||
// name: (context) => 'Charts', //TODO S.of(context).charts,
|
||||
// image: 'assets/new-ui/navbar/charts.svg',
|
||||
// key: ValueKey('dashboard_page_charts_action_button_key'),
|
||||
// onTap: () {},
|
||||
// );
|
||||
static NewMainActions chartsAction = NewMainActions._(
|
||||
name: (context) => S.of(context).charts,
|
||||
image: "assets/new-ui/navbar/charts.svg",
|
||||
key: const ValueKey("dashboard_page_charts_action_button_key"),
|
||||
onTap: () {},
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -309,7 +309,7 @@ Future<void> initializeAppConfigs({bool loadWallet = true}) async {
|
||||
payjoinSessionSource: payjoinSessionSource,
|
||||
anonpayInvoiceInfo: anonpayInvoiceInfo,
|
||||
havenSeedStore: havenSeedStore,
|
||||
initialMigrationVersion: 71,
|
||||
initialMigrationVersion: 72,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:cake_wallet/utils/show_pop_up.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LongPressPopupBuilder extends StatelessWidget {
|
||||
const LongPressPopupBuilder(
|
||||
{super.key, required this.child, required this.popup, this.spacing = 8});
|
||||
|
||||
final Widget child;
|
||||
final Widget popup;
|
||||
final double spacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onLongPress: () {
|
||||
final RenderBox renderBox = context.findRenderObject() as RenderBox;
|
||||
final offset = renderBox.localToGlobal(Offset.zero);
|
||||
final size = renderBox.size;
|
||||
|
||||
showPopUp(
|
||||
context: context,
|
||||
builder: (context) => _buildPopup(context, offset, size),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPopup(BuildContext context, Offset offset, Size size) {
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: offset.dx,
|
||||
top: offset.dy,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: offset.dx * 2,
|
||||
top: offset.dy + size.height + spacing,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: popup,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import "package:cake_wallet/new-ui/model/charts/price_data.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:cw_core/db/sqlite.dart";
|
||||
import "package:sqflite/sqflite.dart";
|
||||
|
||||
class ChartsAsset {
|
||||
const ChartsAsset({required this.asset, required this.isFavorite});
|
||||
|
||||
factory ChartsAsset.fromJson(Map<String, dynamic> json) => ChartsAsset(
|
||||
asset: currencyFromApiString(json["asset"] as String) as CryptoCurrency,
|
||||
isFavorite: json["isFavorite"] != 0,
|
||||
);
|
||||
|
||||
final CryptoCurrency asset;
|
||||
final bool isFavorite;
|
||||
|
||||
static const tableName = "ChartsAssets";
|
||||
|
||||
Map<String, dynamic> toJson() => {"asset": asset.apiString, "isFavorite": isFavorite ? 1 : 0};
|
||||
|
||||
static Future<List<ChartsAsset>> get() async {
|
||||
final json = await db!.query(tableName);
|
||||
return List.generate(json.length, (index) => ChartsAsset.fromJson(json[index]));
|
||||
}
|
||||
|
||||
Future<void> insert() async {
|
||||
await db!.insert(tableName, toJson(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
Future<void> remove() async {
|
||||
await db!.delete(tableName, where: "asset = ?", whereArgs: [asset.apiString]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
extension DateTimeX on DateTime {
|
||||
int get secondsSinceEpoch => millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
static DateTime fromSecondsSinceEpoch(int seconds) =>
|
||||
DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import "dart:convert";
|
||||
|
||||
import "package:cake_wallet/.secrets.g.dart" as secrets;
|
||||
import "package:cake_wallet/new-ui/model/charts/datetime_extension.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/price_data.dart";
|
||||
import "package:cw_core/amount/money.dart";
|
||||
import "package:cw_core/currency.dart";
|
||||
import "package:cw_core/utils/print_verbose.dart";
|
||||
import "package:cw_core/utils/proxy_wrapper.dart";
|
||||
|
||||
const priceApiHost = "prices.cakewallet.com";
|
||||
|
||||
class PriceRequest {
|
||||
const PriceRequest({
|
||||
required this.interval,
|
||||
required this.from,
|
||||
required this.to,
|
||||
this.beginTime,
|
||||
this.count,
|
||||
});
|
||||
|
||||
final DateTime? beginTime;
|
||||
final Duration interval;
|
||||
final int? count;
|
||||
final Currency from;
|
||||
final Currency to;
|
||||
|
||||
Uri get uri => Uri.https(priceApiHost, "/v3/rates", {
|
||||
"start": (beginTime?.secondsSinceEpoch ?? 0).toString(),
|
||||
"end": DateTime.now().secondsSinceEpoch.toString(),
|
||||
"interval": "${interval.inSeconds}",
|
||||
if (count != null) "count": count.toString(),
|
||||
"quote": from.apiString,
|
||||
"base": to.apiString,
|
||||
});
|
||||
}
|
||||
|
||||
class PriceApiClient {
|
||||
static Future<Map<String, dynamic>?> _getJson(Uri uri) async {
|
||||
final resp =
|
||||
await ProxyWrapper().get(headers: {"x-api-key": secrets.fiatApiKey}, clearnetUri: uri);
|
||||
if (!(resp.statusCode >= 200 && resp.statusCode < 300)) {
|
||||
printV("server returned code: ${resp.statusCode}\nuri: ${uri}\nresp body: ${resp.body}");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return jsonDecode(resp.body) as Map<String, dynamic>?;
|
||||
} catch (e) {
|
||||
printV("failed to decode response for ${uri.host}/${uri.path}: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<PriceData>> getPrices(PriceRequest request) async {
|
||||
final List<PriceData> ret = [];
|
||||
printV(request.uri);
|
||||
final data = await _getJson(request.uri);
|
||||
if (data == null) {
|
||||
return [];
|
||||
}
|
||||
final results = data["results"] as Map<String, dynamic>?;
|
||||
if (results == null) {
|
||||
printV(data.toString());
|
||||
return [];
|
||||
}
|
||||
printV(data.toString());
|
||||
for (final time in results.keys) {
|
||||
ret.add(
|
||||
PriceData(
|
||||
time: DateTimeX.fromSecondsSinceEpoch(int.parse(time)),
|
||||
base: request.from,
|
||||
quote: Money.parse((results[time] as num).toStringAsFixed(20), request.to, strictParsing: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import "package:cake_wallet/entities/fiat_currency.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/datetime_extension.dart";
|
||||
import "package:cw_core/amount/money.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:cw_core/currency.dart";
|
||||
import "package:cw_core/db/sqlite.dart";
|
||||
import "package:sqflite/sqflite.dart";
|
||||
|
||||
Currency currencyFromApiString(String key) {
|
||||
final parts = key.split(".");
|
||||
final type = parts[0];
|
||||
final id = parts[1];
|
||||
|
||||
switch (type) {
|
||||
case "fiat":
|
||||
return FiatCurrency.deserialize(raw: id);
|
||||
case "crypto":
|
||||
return CryptoCurrency.fromString(id);
|
||||
case "evm":
|
||||
throw UnimplementedError();
|
||||
case "sol":
|
||||
throw UnimplementedError();
|
||||
}
|
||||
throw Exception("unknown api string");
|
||||
}
|
||||
|
||||
class PriceData implements Comparable<PriceData> {
|
||||
const PriceData({required this.time, required this.base, required this.quote,});
|
||||
|
||||
factory PriceData.fromJson(Map<String, dynamic> json) => PriceData(
|
||||
time: DateTimeX.fromSecondsSinceEpoch(json["timestamp"] as int),
|
||||
base: currencyFromApiString(json["fromCurrency"] as String),
|
||||
quote: Money.parse(
|
||||
json["price"] as String, currencyFromApiString(json["toCurrency"] as String),
|
||||
strictParsing: false),
|
||||
);
|
||||
final DateTime time;
|
||||
final Currency base;
|
||||
final Money quote;
|
||||
|
||||
static const tableName = "PriceData";
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"timestamp": time.secondsSinceEpoch.toString(),
|
||||
"price": quote.toString(),
|
||||
"fromCurrency": base.apiString,
|
||||
"toCurrency": quote.currency.apiString,
|
||||
};
|
||||
|
||||
static Future<List<PriceData>> get(
|
||||
Currency from,
|
||||
Currency to,
|
||||
DateTime? start,
|
||||
DateTime? end,
|
||||
) async {
|
||||
final json = await db!.query(
|
||||
tableName,
|
||||
where: "fromCurrency = ? AND toCurrency = ? AND timestamp >= ? AND timestamp <= ?",
|
||||
whereArgs: [
|
||||
from.apiString,
|
||||
to.apiString,
|
||||
start?.secondsSinceEpoch ?? 0,
|
||||
// dart doesn't have INT_MAX, apparently.
|
||||
end?.secondsSinceEpoch ?? 0x7FFFFFFFFFFFFFFF,
|
||||
],
|
||||
);
|
||||
return List.generate(json.length, (index) => PriceData.fromJson(json[index]));
|
||||
}
|
||||
|
||||
Future<void> insert() async {
|
||||
await db!.insert(tableName, toJson(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
static Future<void> insertMany(Iterable<PriceData> data) async {
|
||||
if (data.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final batch = db!.batch();
|
||||
|
||||
for (final datum in data) {
|
||||
batch.insert(
|
||||
tableName,
|
||||
datum.toJson(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is PriceData &&
|
||||
time == other.time &&
|
||||
base == other.base &&
|
||||
quote.currency == other.quote.currency;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(time, base, quote.currency);
|
||||
|
||||
@override
|
||||
int compareTo(PriceData other) => time.compareTo(other.time);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import "package:cake_wallet/new-ui/model/charts/price_api_client.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/price_data.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/util/chart_range.dart";
|
||||
import "package:cw_core/currency.dart";
|
||||
import "package:cw_core/utils/print_verbose.dart";
|
||||
|
||||
abstract class PriceSource {
|
||||
const PriceSource();
|
||||
|
||||
Future<List<PriceData>> get(
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
Currency from,
|
||||
Currency to,
|
||||
Duration interval,
|
||||
);
|
||||
}
|
||||
|
||||
mixin UpdatablePriceSource {
|
||||
Future<void> update(Iterable<PriceData> newData);
|
||||
}
|
||||
|
||||
class DatabasePriceSource extends PriceSource with UpdatablePriceSource {
|
||||
const DatabasePriceSource();
|
||||
|
||||
@override
|
||||
Future<List<PriceData>> get(
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
Currency from,
|
||||
Currency to,
|
||||
Duration interval,
|
||||
) =>
|
||||
PriceData.get(from, to, start, end);
|
||||
|
||||
@override
|
||||
Future<void> update(Iterable<PriceData> newData) async {
|
||||
await PriceData.insertMany(newData);
|
||||
}
|
||||
}
|
||||
|
||||
class ApiPriceSource extends PriceSource {
|
||||
const ApiPriceSource();
|
||||
|
||||
@override
|
||||
Future<List<PriceData>> get(
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
Currency from,
|
||||
Currency to,
|
||||
Duration interval,
|
||||
) =>
|
||||
PriceApiClient.getPrices(
|
||||
PriceRequest(beginTime: start, interval: interval, from: from, to: to),
|
||||
);
|
||||
}
|
||||
|
||||
class PriceStore {
|
||||
static const priceSources = [
|
||||
// TODO InMemoryPriceSource() - easily implementable w this pattern but idk if we need to optimize this that much
|
||||
DatabasePriceSource(),
|
||||
ApiPriceSource(),
|
||||
];
|
||||
|
||||
Future<List<PriceData>> getPrices(Currency from, Currency to, ChartRange range) async {
|
||||
final Set<PriceData> data = {};
|
||||
|
||||
final end = DateTime.now();
|
||||
DateTime? start;
|
||||
if (range.duration == null) {
|
||||
start = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
} else {
|
||||
start = end.subtract(range.duration!);
|
||||
}
|
||||
start = _alignedStart(start, range.dataPrecision);
|
||||
final alignedStart = start;
|
||||
|
||||
for (final source in priceSources) {
|
||||
final sourceData = await source.get(start!, end, from, to, range.dataPrecision);
|
||||
data.addAll(sourceData);
|
||||
start = _firstUnavailablePrice(data.toList(), range.dataPrecision, start, end);
|
||||
if (start == null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (final source in priceSources) {
|
||||
if (source case final UpdatablePriceSource s) {
|
||||
await s.update(data);
|
||||
}
|
||||
}
|
||||
|
||||
return _alignedData(alignedStart, end, data, range.dataPrecision);
|
||||
}
|
||||
|
||||
static List<PriceData> _alignedData(
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
Iterable<PriceData> data,
|
||||
Duration precision,
|
||||
) {
|
||||
final ret = data
|
||||
.where((datum) => datum.time.millisecondsSinceEpoch % precision.inMilliseconds == 0)
|
||||
.toList();
|
||||
ret.sort((a, b) => a.time.compareTo(b.time));
|
||||
return ret;
|
||||
}
|
||||
|
||||
static DateTime _alignedStart(DateTime start, Duration precision) {
|
||||
final alignedStartMs =
|
||||
(start.millisecondsSinceEpoch ~/ precision.inMilliseconds) * precision.inMilliseconds;
|
||||
return DateTime.fromMillisecondsSinceEpoch(alignedStartMs);
|
||||
}
|
||||
|
||||
static DateTime? _firstUnavailablePrice(
|
||||
List<PriceData> prices,
|
||||
Duration precision,
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
) {
|
||||
if (prices.isEmpty) {
|
||||
return start;
|
||||
}
|
||||
prices.sort();
|
||||
final last = prices.last;
|
||||
printV("last.time: ${last.time.toIso8601String()} end: ${end.toIso8601String()}");
|
||||
if (last.time.isAfter(end.subtract(precision)) ||
|
||||
last.time.isAtSameMomentAs(end.subtract(precision))) {
|
||||
return null;
|
||||
}
|
||||
return last.time.add(precision);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
class ChartRange {
|
||||
const ChartRange._(this.duration, this.displayText, this.dataPrecision);
|
||||
|
||||
final Duration? duration;
|
||||
final String displayText;
|
||||
|
||||
// api can handle any precision up to 5min, regardless of data range.
|
||||
// we set a "preferred" precision for 2 reasons:
|
||||
// - less precision means less data sent, less bandwidth used, and thus less load on the backend
|
||||
// - less data points means the chart is easier to look through on a small screen
|
||||
final Duration dataPrecision;
|
||||
|
||||
static const oneHour = ChartRange._(Duration(hours: 1), "1H", Duration(minutes: 5));
|
||||
static const oneDay = ChartRange._(Duration(days: 1), "1D", Duration(minutes: 15));
|
||||
static const sevenDays = ChartRange._(Duration(days: 7), "7D", Duration(hours: 1));
|
||||
static const thirtyDays = ChartRange._(Duration(days: 30), "30D", Duration(hours: 4));
|
||||
static const oneYear = ChartRange._(Duration(days: 365), "1Y", Duration(days: 1));
|
||||
static const all = ChartRange._(null, "ALL", Duration(days: 1));
|
||||
|
||||
static const ranges = [oneHour, oneDay, sevenDays, thirtyDays, oneYear, all];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import "package:cake_wallet/new-ui/model/charts/util/price_change_direction.dart";
|
||||
import "package:cw_core/amount/money.dart";
|
||||
|
||||
class PriceChangeData implements Comparable<PriceChangeData> {
|
||||
const PriceChangeData({required this.direction, required this.amount, required this.percentage});
|
||||
|
||||
final PriceChangeDirection direction;
|
||||
final Money amount;
|
||||
final String percentage;
|
||||
|
||||
@override
|
||||
int compareTo(PriceChangeData other) {
|
||||
final double thisValue =
|
||||
double.parse(percentage) * (direction == PriceChangeDirection.up ? 1 : -1);
|
||||
final double otherValue =
|
||||
double.parse(other.percentage) * (other.direction == PriceChangeDirection.up ? 1 : -1);
|
||||
return thisValue.compareTo(otherValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import "dart:ui";
|
||||
|
||||
class PriceChangeDirection {
|
||||
|
||||
const PriceChangeDirection._(this.color, this.symbol);
|
||||
final Color color;
|
||||
final String symbol;
|
||||
|
||||
static const up = PriceChangeDirection._(Color(0xFF6FC84E), "+");
|
||||
static const down = PriceChangeDirection._(Color(0xFFEA696F), "-");
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import "package:cake_wallet/generated/i18n.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/util/price_change_data.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
|
||||
const List<CryptoCurrency> cryptoCurrenciesByMarketcap = [
|
||||
CryptoCurrency.btc,
|
||||
CryptoCurrency.eth,
|
||||
CryptoCurrency.usdt,
|
||||
CryptoCurrency.xrp,
|
||||
CryptoCurrency.bnb,
|
||||
CryptoCurrency.sol,
|
||||
CryptoCurrency.usdc,
|
||||
CryptoCurrency.doge,
|
||||
CryptoCurrency.trx,
|
||||
CryptoCurrency.ton,
|
||||
CryptoCurrency.bch,
|
||||
CryptoCurrency.ltc,
|
||||
CryptoCurrency.xmr,
|
||||
CryptoCurrency.matic,
|
||||
CryptoCurrency.arb,
|
||||
CryptoCurrency.dai,
|
||||
CryptoCurrency.paxg,
|
||||
CryptoCurrency.zec,
|
||||
CryptoCurrency.dcr,
|
||||
CryptoCurrency.nano,
|
||||
CryptoCurrency.zano,
|
||||
CryptoCurrency.deuro,
|
||||
CryptoCurrency.wow,
|
||||
];
|
||||
|
||||
abstract class PriceDataSortCriterium {
|
||||
const PriceDataSortCriterium();
|
||||
|
||||
String get name;
|
||||
|
||||
String get iconPath;
|
||||
|
||||
int comparator(
|
||||
PriceChangeData changeDataA,
|
||||
PriceChangeData changeDataB,
|
||||
CryptoCurrency a,
|
||||
CryptoCurrency b,
|
||||
);
|
||||
|
||||
static const all = [
|
||||
MarketcapSortCriterium(),
|
||||
GainsSortCriterium(),
|
||||
LossesSortCriterium(),
|
||||
AlphabeticalSortCriterium(),
|
||||
];
|
||||
}
|
||||
|
||||
class AlphabeticalSortCriterium extends PriceDataSortCriterium {
|
||||
const AlphabeticalSortCriterium();
|
||||
|
||||
@override
|
||||
String get name => S.current.alphabetical;
|
||||
|
||||
@override
|
||||
String get iconPath => "assets/new-ui/charts_sort_criteria/alpha.svg";
|
||||
|
||||
@override
|
||||
int comparator(
|
||||
PriceChangeData changeDataA,
|
||||
PriceChangeData changeDataB,
|
||||
CryptoCurrency a,
|
||||
CryptoCurrency b,
|
||||
) =>
|
||||
(a.fullName ?? a.title).compareTo(b.fullName ?? b.title);
|
||||
}
|
||||
|
||||
class MarketcapSortCriterium extends PriceDataSortCriterium {
|
||||
const MarketcapSortCriterium();
|
||||
|
||||
@override
|
||||
String get name => S.current.marketcap;
|
||||
|
||||
@override
|
||||
String get iconPath => "assets/new-ui/charts_sort_criteria/marketcap.svg";
|
||||
|
||||
@override
|
||||
int comparator(
|
||||
PriceChangeData changeDataA,
|
||||
PriceChangeData changeDataB,
|
||||
CryptoCurrency a,
|
||||
CryptoCurrency b,
|
||||
) {
|
||||
final aIndex = cryptoCurrenciesByMarketcap.indexOf(a);
|
||||
final bIndex = cryptoCurrenciesByMarketcap.indexOf(b);
|
||||
|
||||
if (aIndex != -1 && bIndex != -1) {
|
||||
return aIndex.compareTo(bIndex);
|
||||
}
|
||||
if (aIndex != -1) {
|
||||
return -1;
|
||||
}
|
||||
if (bIndex != -1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (a.fullName ?? a.title).compareTo(b.fullName ?? b.title);
|
||||
}
|
||||
}
|
||||
|
||||
class GainsSortCriterium extends PriceDataSortCriterium {
|
||||
const GainsSortCriterium();
|
||||
|
||||
@override
|
||||
String get name => S.current.gains;
|
||||
|
||||
@override
|
||||
String get iconPath => "assets/new-ui/charts_sort_criteria/gains.svg";
|
||||
|
||||
@override
|
||||
int comparator(
|
||||
PriceChangeData changeDataA,
|
||||
PriceChangeData changeDataB,
|
||||
CryptoCurrency a,
|
||||
CryptoCurrency b,
|
||||
) =>
|
||||
changeDataB.compareTo(changeDataA);
|
||||
}
|
||||
|
||||
class LossesSortCriterium extends PriceDataSortCriterium {
|
||||
const LossesSortCriterium();
|
||||
|
||||
@override
|
||||
String get name => S.current.losses;
|
||||
|
||||
@override
|
||||
String get iconPath => "assets/new-ui/charts_sort_criteria/losses.svg";
|
||||
|
||||
@override
|
||||
int comparator(
|
||||
PriceChangeData changeDataA,
|
||||
PriceChangeData changeDataB,
|
||||
CryptoCurrency a,
|
||||
CryptoCurrency b,
|
||||
) =>
|
||||
changeDataA.compareTo(changeDataB);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import 'dart:io';
|
||||
import 'package:cake_wallet/di.dart';
|
||||
import 'package:cake_wallet/entities/preferences_key.dart';
|
||||
import "package:cake_wallet/new-ui/page_open_listener.dart";
|
||||
import "package:cake_wallet/new-ui/page_open_listener.dart";
|
||||
import 'package:cake_wallet/new-ui/pages/charts_page.dart';
|
||||
import 'package:cake_wallet/new-ui/pages/home_page.dart';
|
||||
import 'package:cake_wallet/new-ui/widgets/changelog_modal.dart';
|
||||
import 'package:cake_wallet/src/screens/contact/contact_list_page.dart';
|
||||
@@ -36,7 +38,7 @@ class NewDashboard extends StatefulWidget {
|
||||
getIt.get<WalletListPage>(),
|
||||
getIt.get<ContactListPage>(),
|
||||
getIt.get<CakeFeaturesPage>(),
|
||||
Placeholder(),
|
||||
getIt.get<ChartsPage>()
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
|
||||
|
||||
abstract interface class PageOpenListener {
|
||||
void onPageOpen();
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ import 'dart:ui';
|
||||
import 'package:cake_wallet/di.dart';
|
||||
import 'package:cake_wallet/generated/i18n.dart';
|
||||
import 'package:cake_wallet/monero/monero.dart';
|
||||
import 'package:cake_wallet/new-ui/long_press_popup.dart';
|
||||
import 'package:cake_wallet/new-ui/widgets/long_press_menu/long_press_popup.dart';
|
||||
import 'package:cake_wallet/new-ui/widgets/addresses_page/address_info.dart';
|
||||
import 'package:cake_wallet/new-ui/widgets/addresses_page/address_label_input.dart';
|
||||
import 'package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart';
|
||||
import 'package:cake_wallet/new-ui/widgets/long_press_menu.dart';
|
||||
import 'package:cake_wallet/new-ui/widgets/long_press_menu/long_press_menu.dart';
|
||||
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
|
||||
import 'package:cake_wallet/routes.dart';
|
||||
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
|
||||
|
||||
@@ -27,6 +27,13 @@ import "package:flutter/material.dart";
|
||||
import "package:flutter_mobx/flutter_mobx.dart";
|
||||
import "package:mobx/mobx.dart";
|
||||
|
||||
class NewBuySellParams {
|
||||
NewBuySellParams({required this.mode, this.initialCurrency});
|
||||
|
||||
final BuySellPageMode mode;
|
||||
final CryptoCurrency? initialCurrency;
|
||||
}
|
||||
|
||||
class NewBuySellAmountPage extends StatefulWidget {
|
||||
const NewBuySellAmountPage({required this.buySellViewModel, super.key});
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import "package:cake_wallet/new-ui/page_open_listener.dart";
|
||||
import "package:cake_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/charts_page/asset_grid.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/charts_page/asset_grid_header.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/charts_page/chart_header.dart";
|
||||
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
|
||||
import "package:flutter/cupertino.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_bloc/flutter_bloc.dart";
|
||||
|
||||
class ChartsPage extends StatelessWidget implements PageOpenListener {
|
||||
const ChartsPage({required this.chartsBloc, super.key});
|
||||
|
||||
final ChartsBloc chartsBloc;
|
||||
|
||||
@override
|
||||
void onPageOpen() {
|
||||
chartsBloc.add(Init());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) => chartsBloc,
|
||||
child: BlocBuilder<ChartsBloc, ChartsState>(
|
||||
builder: (context, state) {
|
||||
if (state is ChartsInitial) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 24,
|
||||
children: [
|
||||
CakeImageWidget(
|
||||
width: 36,
|
||||
height: 36,
|
||||
imageUrl: "assets/new-ui/navbar/charts.svg",
|
||||
),
|
||||
CupertinoActivityIndicator(),
|
||||
if (kDebugMode)
|
||||
Text(
|
||||
"devs: if this doesn't go away in a few seconds, either your device is painfully slow or the db is messed up",
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.surface,
|
||||
Theme.of(context).colorScheme.surfaceDim,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
|
||||
sliver: CupertinoSliverRefreshControl(
|
||||
refreshTriggerPullDistance: 160,
|
||||
refreshIndicatorExtent: 90,
|
||||
onRefresh: () async => chartsBloc.add(PageRefreshed()),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
spacing: 24,
|
||||
children: [
|
||||
if (state is ChartsStateWithData)
|
||||
ChartHeader(
|
||||
currency: state.pinnedCurrency,
|
||||
chartHeight: 100,
|
||||
chartPadding: 22,
|
||||
centered: false,
|
||||
favorite: true,
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 18),
|
||||
child: Column(
|
||||
spacing: 24,
|
||||
children: [ChartsAssetGridHeader(), ChartsAssetGrid()],
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 96,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import "dart:async";
|
||||
|
||||
import "package:bloc/bloc.dart";
|
||||
import "package:bloc_concurrency/bloc_concurrency.dart";
|
||||
import "package:cake_wallet/core/utilities.dart";
|
||||
import "package:cake_wallet/entities/fiat_currency.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/charts_asset.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/price_data.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/price_store.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/util/chart_range.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/util/price_change_data.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/util/price_change_direction.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/util/price_data_sort_criteria.dart";
|
||||
import "package:cake_wallet/store/app_store.dart";
|
||||
import "package:cw_core/amount/money.dart";
|
||||
import "package:cw_core/crypto_amount_format.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
part "charts_event.dart";
|
||||
|
||||
part "charts_state.dart";
|
||||
|
||||
class ChartsBloc extends Bloc<ChartsEvent, ChartsState> {
|
||||
ChartsBloc({required this.priceStore, required this.appStore}) : super(const ChartsInitial()) {
|
||||
on<RangeChanged>(_onRangeChanged, transformer: sequential());
|
||||
on<SortingCriteriumChanged>(_onSortingCriteriumChanged);
|
||||
on<CurrencyAdded>(_onCurrencyAdded, transformer: sequential());
|
||||
on<CurrencyRemoved>(_onCurrencyRemoved, transformer: sequential());
|
||||
on<CurrencyPinned>(_onCurrencyPinned, transformer: sequential());
|
||||
on<PageRefreshed>(_onPageRefreshed, transformer: sequential());
|
||||
on<PageLoadStarted>(_onPageLoadStarted, transformer: restartable());
|
||||
on<Init>(_init);
|
||||
}
|
||||
|
||||
final PriceStore priceStore;
|
||||
final AppStore appStore;
|
||||
|
||||
Future<void> _init(Init event, Emitter<ChartsState> emit) async {
|
||||
if (state is! ChartsInitial) {
|
||||
return;
|
||||
}
|
||||
|
||||
final assets = await ChartsAsset.get();
|
||||
|
||||
if (assets.isEmpty) {
|
||||
// generally this shouldn't happen, but i wanna make sure we can recover from a broken db
|
||||
assets.add(const ChartsAsset(asset: CryptoCurrency.btc, isFavorite: true));
|
||||
unawaited(assets.first.insert());
|
||||
}
|
||||
|
||||
if (assets.firstWhereOrNull((item) => item.isFavorite) == null) {
|
||||
assets.first = ChartsAsset(asset: assets.first.asset, isFavorite: true);
|
||||
unawaited(assets.first.insert());
|
||||
}
|
||||
|
||||
emit(
|
||||
ChartsLoading(
|
||||
pinnedCurrency: assets.firstWhere((item) => item.isFavorite).asset,
|
||||
currencies: assets.map((item) => item.asset).toList(),
|
||||
range: ChartRange.all,
|
||||
sortCriterium: PriceDataSortCriterium.all.first,
|
||||
),
|
||||
);
|
||||
add(PageLoadStarted());
|
||||
}
|
||||
|
||||
Future<void> _onPageLoadStarted(PageLoadStarted event, Emitter<ChartsState> emit) async {
|
||||
if (state case final ChartsStateWithData s) {
|
||||
final Map<CryptoCurrency, List<PriceData>> data = {};
|
||||
final currencies = s.currencies.toList();
|
||||
final range = s.range;
|
||||
for (final curr in currencies) {
|
||||
data[curr] = await priceStore.getPrices(appStore.settingsStore.fiatCurrency, curr, range);
|
||||
}
|
||||
emit(
|
||||
ChartsLoaded(
|
||||
pinnedCurrency: s.pinnedCurrency,
|
||||
prices: data,
|
||||
range: s.range,
|
||||
sortCriterium: s.sortCriterium,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
throw Exception("attempted price load without currency data");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRangeChanged(
|
||||
RangeChanged event,
|
||||
Emitter<ChartsState> emit,
|
||||
) async {
|
||||
if (state case final ChartsStateWithData s) {
|
||||
emit(s.toLoading().copyWith(range: event.newRange));
|
||||
add(PageLoadStarted());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSortingCriteriumChanged(
|
||||
SortingCriteriumChanged event,
|
||||
Emitter<ChartsState> emit,
|
||||
) async {
|
||||
if (state case final ChartsStateWithData s) {
|
||||
emit(s.copyWith(sortCriterium: event.newCriterium));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCurrencyAdded(
|
||||
CurrencyAdded event,
|
||||
Emitter<ChartsState> emit,
|
||||
) async {
|
||||
if (state case final ChartsStateWithData s) {
|
||||
final newCurrencies = s.currencies..add(event.currency);
|
||||
await ChartsAsset(asset: event.currency, isFavorite: false).insert();
|
||||
emit(s.toLoading().copyWith(currencies: newCurrencies));
|
||||
add(PageLoadStarted());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCurrencyRemoved(
|
||||
CurrencyRemoved event,
|
||||
Emitter<ChartsState> emit,
|
||||
) async {
|
||||
if (state case final ChartsStateWithData s) {
|
||||
final newCurrencies = s.currencies..remove(event.currency);
|
||||
|
||||
if (newCurrencies.isEmpty) {
|
||||
throw Exception(
|
||||
"removed the last currency ${kDebugMode ? "- your ui should block this! what did you do?" : ""}",
|
||||
);
|
||||
}
|
||||
|
||||
final CryptoCurrency newPin;
|
||||
if (s.pinnedCurrency == event.currency) {
|
||||
newPin = newCurrencies.first;
|
||||
await ChartsAsset(asset: s.pinnedCurrency, isFavorite: false).insert();
|
||||
await ChartsAsset(asset: newPin, isFavorite: true).insert();
|
||||
} else {
|
||||
newPin = s.pinnedCurrency;
|
||||
}
|
||||
|
||||
await ChartsAsset(asset: event.currency, isFavorite: false).remove();
|
||||
|
||||
emit(s.toLoading().copyWith(currencies: newCurrencies, pinnedCurrency: newPin));
|
||||
add(PageLoadStarted());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCurrencyPinned(
|
||||
CurrencyPinned event,
|
||||
Emitter<ChartsState> emit,
|
||||
) async {
|
||||
if (state case final ChartsStateWithData s) {
|
||||
if (s.pinnedCurrency == event.currency) {
|
||||
return;
|
||||
}
|
||||
await ChartsAsset(asset: s.pinnedCurrency, isFavorite: false).insert();
|
||||
await ChartsAsset(asset: event.currency, isFavorite: true).insert();
|
||||
emit(s.toLoading().copyWith(pinnedCurrency: event.currency));
|
||||
add(PageLoadStarted());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onPageRefreshed(
|
||||
PageRefreshed event,
|
||||
Emitter<ChartsState> emit,
|
||||
) async {
|
||||
if (state case final ChartsStateWithData s) {
|
||||
emit(s.toLoading());
|
||||
add(PageLoadStarted());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
part of 'charts_bloc.dart';
|
||||
|
||||
@immutable
|
||||
sealed class ChartsEvent {
|
||||
const ChartsEvent();
|
||||
}
|
||||
|
||||
class RangeChanged extends ChartsEvent {
|
||||
const RangeChanged({required this.newRange});
|
||||
|
||||
final ChartRange newRange;
|
||||
}
|
||||
|
||||
class SortingCriteriumChanged extends ChartsEvent {
|
||||
const SortingCriteriumChanged({required this.newCriterium});
|
||||
|
||||
final PriceDataSortCriterium newCriterium;
|
||||
}
|
||||
|
||||
class CurrencyAdded extends ChartsEvent {
|
||||
const CurrencyAdded({required this.currency});
|
||||
|
||||
final CryptoCurrency currency;
|
||||
}
|
||||
|
||||
class CurrencyRemoved extends ChartsEvent {
|
||||
const CurrencyRemoved({required this.currency});
|
||||
|
||||
final CryptoCurrency currency;
|
||||
}
|
||||
|
||||
class CurrencyPinned extends ChartsEvent {
|
||||
const CurrencyPinned({required this.currency});
|
||||
|
||||
final CryptoCurrency currency;
|
||||
}
|
||||
|
||||
class PageRefreshed extends ChartsEvent {}
|
||||
|
||||
class PageLoadStarted extends ChartsEvent {}
|
||||
|
||||
class Init extends ChartsEvent {}
|
||||
@@ -0,0 +1,132 @@
|
||||
part of "charts_bloc.dart";
|
||||
|
||||
@immutable
|
||||
sealed class ChartsState {
|
||||
const ChartsState();
|
||||
}
|
||||
|
||||
final class ChartsInitial extends ChartsState {
|
||||
const ChartsInitial();
|
||||
}
|
||||
|
||||
abstract final class ChartsStateWithData extends ChartsState {
|
||||
const ChartsStateWithData({
|
||||
required this.pinnedCurrency,
|
||||
required this.range,
|
||||
required this.sortCriterium,
|
||||
});
|
||||
|
||||
final CryptoCurrency pinnedCurrency;
|
||||
|
||||
List<CryptoCurrency> get currencies;
|
||||
|
||||
final PriceDataSortCriterium sortCriterium;
|
||||
final ChartRange range;
|
||||
|
||||
Money? priceFor(CryptoCurrency curr) => null;
|
||||
|
||||
String get fiatTicker => "";
|
||||
|
||||
bool get hasSingleCurrency => currencies.length == 1;
|
||||
|
||||
ChartsStateWithData copyWith({
|
||||
CryptoCurrency? pinnedCurrency,
|
||||
ChartRange? range,
|
||||
PriceDataSortCriterium? sortCriterium,
|
||||
});
|
||||
|
||||
ChartsLoading toLoading() => ChartsLoading(
|
||||
pinnedCurrency: pinnedCurrency,
|
||||
currencies: currencies,
|
||||
range: range,
|
||||
sortCriterium: sortCriterium,
|
||||
);
|
||||
}
|
||||
|
||||
final class ChartsLoading extends ChartsStateWithData {
|
||||
const ChartsLoading({
|
||||
required super.pinnedCurrency,
|
||||
required this.currencies,
|
||||
required super.range,
|
||||
required super.sortCriterium,
|
||||
});
|
||||
|
||||
@override
|
||||
final List<CryptoCurrency> currencies;
|
||||
|
||||
@override
|
||||
ChartsLoading copyWith({
|
||||
CryptoCurrency? pinnedCurrency,
|
||||
List<CryptoCurrency>? currencies,
|
||||
ChartRange? range,
|
||||
PriceDataSortCriterium? sortCriterium,
|
||||
}) =>
|
||||
ChartsLoading(
|
||||
pinnedCurrency: pinnedCurrency ?? this.pinnedCurrency,
|
||||
currencies: currencies ?? this.currencies,
|
||||
range: range ?? this.range,
|
||||
sortCriterium: sortCriterium ?? this.sortCriterium,
|
||||
);
|
||||
}
|
||||
|
||||
final class ChartsLoaded extends ChartsStateWithData {
|
||||
const ChartsLoaded({
|
||||
required super.pinnedCurrency,
|
||||
required Map<CryptoCurrency, List<PriceData>> prices,
|
||||
required super.range,
|
||||
required super.sortCriterium,
|
||||
}) : _prices = prices;
|
||||
final Map<CryptoCurrency, List<PriceData>> _prices;
|
||||
|
||||
@override
|
||||
String get fiatTicker => _prices[_prices.keys.first]?.firstOrNull?.base.name ?? "";
|
||||
|
||||
@override
|
||||
List<CryptoCurrency> get currencies {
|
||||
final list = _prices.keys.toList();
|
||||
list.sort((a, b) => sortCriterium.comparator(changeDataFor(a), changeDataFor(b), a, b));
|
||||
return list;
|
||||
}
|
||||
|
||||
@override
|
||||
Money? priceFor(CryptoCurrency curr) => _prices[curr]?.lastOrNull?.quote;
|
||||
|
||||
PriceChangeData changeDataFor(CryptoCurrency curr) {
|
||||
final latestPrice = _prices[curr]?.lastOrNull?.quote ?? Money.zero(FiatCurrency.usd);
|
||||
final secondLatestPrice = _prices[curr]?.firstOrNull?.quote ?? Money.zero(FiatCurrency.usd);
|
||||
|
||||
final direction =
|
||||
latestPrice >= secondLatestPrice ? PriceChangeDirection.up : PriceChangeDirection.down;
|
||||
final double percentage;
|
||||
if(secondLatestPrice.isZero) {
|
||||
percentage = 0;
|
||||
} else {
|
||||
percentage =
|
||||
(((double.parse(latestPrice.toString()) - double.parse(secondLatestPrice.toString())) /
|
||||
double.parse(secondLatestPrice.toString())) * 100).abs();
|
||||
}
|
||||
final amount = (latestPrice - secondLatestPrice).abs();
|
||||
|
||||
return PriceChangeData(
|
||||
direction: direction,
|
||||
amount: amount,
|
||||
percentage: percentage.toStringAsFixed(2),
|
||||
);
|
||||
}
|
||||
|
||||
List<PriceData> dataFor(CryptoCurrency curr) => _prices[curr] ?? [];
|
||||
|
||||
@override
|
||||
ChartsLoaded copyWith({
|
||||
CryptoCurrency? pinnedCurrency,
|
||||
Map<CryptoCurrency, List<PriceData>>? prices,
|
||||
ChartRange? range,
|
||||
PriceDataSortCriterium? sortCriterium,
|
||||
}) =>
|
||||
ChartsLoaded(
|
||||
pinnedCurrency: pinnedCurrency ?? this.pinnedCurrency,
|
||||
prices: prices ?? _prices,
|
||||
range: range ?? this.range,
|
||||
sortCriterium: sortCriterium ?? this.sortCriterium,
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import "package:cake_wallet/generated/i18n.dart";
|
||||
import "package:cake_wallet/main.dart";
|
||||
import "package:cake_wallet/new-ui/modal_navigator.dart";
|
||||
import "package:cake_wallet/new-ui/pages/buy_sell/buy_sell_amount_page.dart";
|
||||
import "package:cake_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
|
||||
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
|
||||
import "package:cake_wallet/themes/core/theme_extension.dart";
|
||||
import "package:cake_wallet/view_model/buy/buy_sell_view_model.dart";
|
||||
@@ -68,7 +69,7 @@ class BuySellSelectorModal extends StatelessWidget {
|
||||
|
||||
void openBuySellPage(BuildContext context, BuySellPageMode mode) {
|
||||
Navigator.of(context).pop();
|
||||
final page = getIt.get<NewBuySellAmountPage>(param1: mode);
|
||||
final page = getIt.get<NewBuySellAmountPage>(param1: NewBuySellParams(mode: mode));
|
||||
showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
isScrollControlled: true,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import "dart:async";
|
||||
|
||||
import "package:cake_wallet/generated/i18n.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/util/price_change_data.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/util/price_change_direction.dart";
|
||||
import "package:cake_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/charts_page/change_pill.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/charts_page/chart_modal.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/long_press_menu/long_press_footer.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/long_press_menu/long_press_menu.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/long_press_menu/long_press_popup.dart";
|
||||
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
|
||||
import "package:cake_wallet/themes/core/theme_extension.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_bloc/flutter_bloc.dart";
|
||||
|
||||
class ChartsAssetGrid extends StatelessWidget {
|
||||
const ChartsAssetGrid({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocBuilder<ChartsBloc, ChartsState>(
|
||||
builder: (context, state) {
|
||||
if (state is ChartsStateWithData) {
|
||||
final currencies = state.currencies;
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: currencies.length,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10,
|
||||
mainAxisExtent: 105,),
|
||||
itemBuilder: (context, index) {
|
||||
final curr = currencies[index];
|
||||
return ChartsAssetCard(
|
||||
currency: curr,
|
||||
price: state.priceFor(curr)?.toStringWithPrecision(fractionalDigits: 2) ?? "...",
|
||||
ticker: state.fiatTicker,
|
||||
changeData: state is ChartsLoaded ? state.changeDataFor(curr) : null,
|
||||
favorite: curr == state.pinnedCurrency,
|
||||
isSingleCurrency: state.hasSingleCurrency,
|
||||
);
|
||||
},);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class ChartsAssetCard extends StatelessWidget {
|
||||
const ChartsAssetCard(
|
||||
{super.key,
|
||||
required this.currency,
|
||||
required this.price,
|
||||
required this.ticker,
|
||||
this.changeData,
|
||||
required this.favorite,
|
||||
required this.isSingleCurrency,});
|
||||
|
||||
final CryptoCurrency currency;
|
||||
final String price;
|
||||
final String ticker;
|
||||
final PriceChangeData? changeData;
|
||||
final bool favorite;
|
||||
final bool isSingleCurrency;
|
||||
|
||||
String get displayPrice {
|
||||
try {
|
||||
final priceDouble = double.parse(price);
|
||||
if (priceDouble > 10000) {
|
||||
return priceDouble.toStringAsFixed(0);
|
||||
} else {
|
||||
return priceDouble.toStringAsFixed(2);
|
||||
}
|
||||
} catch (_) {
|
||||
return price;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LongPressPopupBuilder(
|
||||
popup: LongPressMenu(items: [
|
||||
LongPressMenuItem(
|
||||
label: S.of(context).favorite,
|
||||
iconPath: "assets/new-ui/favorite.svg",
|
||||
onSelected: () {
|
||||
context.read<ChartsBloc>().add(CurrencyPinned(currency: currency));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
color: favorite ? Theme.of(context).colorScheme.error : null,),
|
||||
LongPressMenuItem(
|
||||
label: S.of(context).remove,
|
||||
iconPath: "assets/new-ui/address_hide.svg",
|
||||
onSelected: () {
|
||||
if (!isSingleCurrency) {
|
||||
context.read<ChartsBloc>().add(CurrencyRemoved(currency: currency));
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
color: isSingleCurrency ? Theme.of(context).colorScheme.onSurfaceVariant : null,),
|
||||
],),
|
||||
footer:
|
||||
isSingleCurrency ? LongPressFooter(text: S.of(context).cannot_remove_last_asset) : null,
|
||||
child: GestureDetector(
|
||||
onTap: () async {
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
final res = await showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => ChartModal(
|
||||
currency: currency,
|
||||
isFavorite: favorite,
|
||||
),);
|
||||
if (res != null && res is bool && res) {
|
||||
context.read<ChartsBloc>().add(CurrencyPinned(currency: currency));
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
width: 1, color: Theme.of(context).colorScheme.surfaceContainerHighest,),
|
||||
gradient: LinearGradient(colors: [
|
||||
context.customColors.cardGradientColorPrimary,
|
||||
context.customColors.cardGradientColorSecondary,
|
||||
], begin: Alignment.topCenter, end: Alignment.bottomCenter,),),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 5,
|
||||
children: [
|
||||
if (changeData != null)
|
||||
RotatedBox(
|
||||
quarterTurns:
|
||||
changeData!.direction == PriceChangeDirection.up ? 0 : 2,
|
||||
child: CakeImageWidget(
|
||||
imageUrl: "assets/new-ui/price_change_arrow.svg",
|
||||
width: 8,
|
||||
height: 8,
|
||||
colorFilter: ColorFilter.mode(
|
||||
changeData!.direction.color, BlendMode.srcIn,),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
currency.title.toUpperCase(),
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
currency.fullName ?? "",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (changeData != null)
|
||||
ChangePill(
|
||||
changePercentage: changeData!.percentage,
|
||||
direction: changeData!.direction,),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: 8,
|
||||
children: [
|
||||
CakeImageWidget(
|
||||
imageUrl: currency.iconPath,
|
||||
width: 24,
|
||||
height: 24,
|
||||
),
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
alignment: Alignment.centerRight,
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
displayPrice,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 20),
|
||||
),
|
||||
Text(
|
||||
ticker,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 20,),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import "package:cake_wallet/generated/i18n.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/util/price_data_sort_criteria.dart";
|
||||
import "package:cake_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_sheet.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/long_press_menu/long_press_menu.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/long_press_menu/long_press_popup.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/modern_button.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_bloc/flutter_bloc.dart";
|
||||
|
||||
class ChartsAssetGridHeader extends StatelessWidget {
|
||||
const ChartsAssetGridHeader({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocBuilder<ChartsBloc, ChartsState>(
|
||||
builder: (context, state) {
|
||||
final bloc = context.read<ChartsBloc>();
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context).followed_assets,
|
||||
style:
|
||||
TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ModernButton.svg(
|
||||
size: 36,
|
||||
iconSize: 16,
|
||||
svgPath: "assets/new-ui/add.svg",
|
||||
onPressed: () => _showCurrencyPicker(context, bloc),
|
||||
),
|
||||
LongPressPopupBuilder(
|
||||
showOnTap: true,
|
||||
popup: LongPressMenu(
|
||||
items: PriceDataSortCriterium.all.map((item) {
|
||||
final isSelected =
|
||||
state is ChartsStateWithData && state.sortCriterium == item;
|
||||
|
||||
return LongPressMenuItem(
|
||||
label: item.name,
|
||||
iconPath: item.iconPath,
|
||||
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
||||
onSelected: () {
|
||||
bloc.add(SortingCriteriumChanged(newCriterium: item));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
child: ModernButton.svg(
|
||||
size: 36,
|
||||
iconSize: 12,
|
||||
svgPath: "assets/new-ui/sort.svg",
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
void _showCurrencyPicker(BuildContext context, ChartsBloc bloc) {
|
||||
CurrencyPickerSheet.show(
|
||||
context: context,
|
||||
args: CurrencyPickerArgs(
|
||||
items: CryptoCurrency.all,
|
||||
onSelected: (item) {
|
||||
bloc.add(CurrencyAdded(currency: item));
|
||||
},
|
||||
showStablesHeader: false,
|
||||
symbolResolver: (item) => item.symbol,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:cake_wallet/new-ui/model/charts/util/price_change_data.dart';
|
||||
import 'package:cake_wallet/new-ui/widgets/charts_page/change_pill.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChangeDisplay extends StatelessWidget {
|
||||
const ChangeDisplay({super.key, required this.changeData, required this.ticker});
|
||||
|
||||
final String ticker;
|
||||
final PriceChangeData changeData;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 10,
|
||||
children: [
|
||||
Text(
|
||||
"${changeData.direction.symbol}${ticker} ${changeData.amount.toStringWithPrecision(fractionalDigits: 2)}",
|
||||
style: TextStyle(fontSize: 16, color: changeData.direction.color),
|
||||
),
|
||||
ChangePill(changePercentage: changeData.percentage, direction: changeData.direction)
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import "package:cake_wallet/new-ui/model/charts/util/price_change_direction.dart";
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
class ChangePill extends StatelessWidget {
|
||||
const ChangePill({super.key, required this.changePercentage, required this.direction});
|
||||
|
||||
final String changePercentage;
|
||||
final PriceChangeDirection direction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: direction.color.withAlpha(52), borderRadius: BorderRadius.circular(999999)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2.5, bottom: 2.5, left: 4, right: 8),
|
||||
child: Text(
|
||||
"${direction.symbol} $changePercentage%",
|
||||
style: TextStyle(color: direction.color),
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import "package:cake_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/charts_page/change_display.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/charts_page/chart_view.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/charts_page/coin_header.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/charts_page/price_header.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/charts_page/range_selector.dart";
|
||||
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
|
||||
import "package:cw_core/amount/money_double.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:flutter/cupertino.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_bloc/flutter_bloc.dart";
|
||||
import "package:intl/intl.dart";
|
||||
|
||||
class ChartHeader extends StatefulWidget {
|
||||
const ChartHeader({
|
||||
super.key,
|
||||
required this.currency,
|
||||
required this.chartHeight,
|
||||
required this.chartPadding,
|
||||
required this.centered,
|
||||
required this.favorite,
|
||||
});
|
||||
|
||||
final CryptoCurrency currency;
|
||||
final double chartHeight;
|
||||
final double chartPadding;
|
||||
final bool centered;
|
||||
final bool favorite;
|
||||
|
||||
@override
|
||||
State<ChartHeader> createState() => _ChartHeaderState();
|
||||
}
|
||||
|
||||
class _ChartHeaderState extends State<ChartHeader> {
|
||||
String? _viewedPrice;
|
||||
String? _viewedTime;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocBuilder<ChartsBloc, ChartsState>(
|
||||
builder: (context, state) {
|
||||
if (state case final ChartsStateWithData s) {
|
||||
return Column(
|
||||
crossAxisAlignment:
|
||||
widget.centered ? CrossAxisAlignment.center : CrossAxisAlignment.start,
|
||||
spacing: 10,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
widget.centered ? CrossAxisAlignment.center : CrossAxisAlignment.start,
|
||||
spacing: 10,
|
||||
children: [
|
||||
if (!widget.favorite)
|
||||
CakeImageWidget(
|
||||
imageUrl: widget.currency.iconPath ?? "",
|
||||
width: 60,
|
||||
height: 60,
|
||||
),
|
||||
ChartViewCoinHeader(currency: widget.currency, isFavorite: widget.favorite),
|
||||
ChartViewPriceHeader(
|
||||
price: _viewedPrice ?? s.priceFor(widget.currency)?.toStringWithPrecision(fractionalDigits: 2) ?? "...",
|
||||
ticker: s.fiatTicker,
|
||||
highlight: _viewedPrice != null,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
widget.centered ? CrossAxisAlignment.center : CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
// has to be constant-size, otherwise will jump around when loading
|
||||
height: 36,
|
||||
alignment: widget.centered ? Alignment.center : Alignment.centerLeft,
|
||||
child: (s is ChartsLoaded)
|
||||
? (_viewedPrice != null && _viewedTime != null)
|
||||
? Text(
|
||||
_viewedTime!,
|
||||
style: TextStyle(
|
||||
fontFamily: "IBM Plex Mono",
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
),
|
||||
)
|
||||
: ChangeDisplay(
|
||||
changeData: s.changeDataFor(widget.currency),
|
||||
ticker: s.fiatTicker,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
if (s is ChartsLoaded)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: widget.chartPadding),
|
||||
child: PriceChart(
|
||||
height: widget.chartHeight,
|
||||
prices: s.dataFor(widget.currency),
|
||||
direction: s.changeDataFor(widget.currency).direction,
|
||||
touchCallback: (event, response) {
|
||||
if (!event.isInterestedForInteractions) {
|
||||
setState(() {
|
||||
_viewedPrice = null;
|
||||
_viewedTime = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final newPrice =
|
||||
response?.lineBarSpots?.firstOrNull?.y.toStringAsFixed(2);
|
||||
final newTime = DateFormat("M/d/y HH:mm").format(
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
response?.lineBarSpots?.firstOrNull?.x.toInt() ?? 0,
|
||||
),
|
||||
);
|
||||
if (_viewedPrice != newPrice) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
_viewedPrice = newPrice;
|
||||
});
|
||||
}
|
||||
if (_viewedTime != newTime) {
|
||||
setState(() {
|
||||
_viewedTime = newTime;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: widget.chartHeight + widget.chartPadding * 2,
|
||||
child: const Center(child: CupertinoActivityIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 1,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withAlpha(128),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ChartRangeSelector(
|
||||
selectedRange: s.range,
|
||||
onRangeSelected: (range) =>
|
||||
context.read<ChartsBloc>().add(RangeChanged(newRange: range)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const CupertinoActivityIndicator();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import "package:cake_wallet/di.dart";
|
||||
import "package:cake_wallet/generated/i18n.dart";
|
||||
import "package:cake_wallet/main.dart";
|
||||
import "package:cake_wallet/new-ui/modal_navigator.dart";
|
||||
import "package:cake_wallet/new-ui/pages/buy_sell/buy_sell_amount_page.dart";
|
||||
import "package:cake_wallet/new-ui/pages/swap_page.dart";
|
||||
import "package:cake_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/charts_page/chart_header.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/coins_page/action_row/coin_action_button.dart";
|
||||
import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
|
||||
import "package:cake_wallet/routes.dart";
|
||||
import "package:cake_wallet/src/screens/buy/buy_sell_page.dart";
|
||||
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
|
||||
import "package:cake_wallet/store/app_store.dart";
|
||||
import "package:cake_wallet/view_model/buy/buy_sell_view_model.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_bloc/flutter_bloc.dart";
|
||||
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
|
||||
|
||||
class ChartModal extends StatelessWidget {
|
||||
const ChartModal({required this.currency, required this.isFavorite, super.key});
|
||||
|
||||
final CryptoCurrency currency;
|
||||
final bool isFavorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) => getIt.get<ChartsBloc>()..add(Init()),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(18)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ModalTopBar(
|
||||
title: "",
|
||||
onLeadingPressed: Navigator.of(context).pop,
|
||||
leadingIcon: const Icon(Icons.close),
|
||||
padding: const EdgeInsets.only(top: 18, left: 18),
|
||||
),
|
||||
ChartHeader(
|
||||
currency: currency,
|
||||
chartHeight: 220,
|
||||
chartPadding: 32,
|
||||
centered: true,
|
||||
favorite: false,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 48,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: MediaQuery.of(context).size.width * 0.05,
|
||||
children: [
|
||||
if (canOpenBuySell) ...[
|
||||
CoinActionButton(
|
||||
icon: CakeImageWidget(
|
||||
width: 36,
|
||||
height: 36,
|
||||
imageUrl: "assets/new-ui/plus.svg",
|
||||
colorFilter: ColorFilter.mode(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
label: S.of(context).buy,
|
||||
action: () => openBuySellPage(context, BuySellPageMode.buy),
|
||||
),
|
||||
CoinActionButton(
|
||||
icon: CakeImageWidget(
|
||||
width: 36,
|
||||
height: 36,
|
||||
imageUrl: "assets/new-ui/sell.svg",
|
||||
colorFilter: ColorFilter.mode(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
label: S.of(context).sell,
|
||||
action: () => openBuySellPage(context, BuySellPageMode.sell),
|
||||
),
|
||||
],
|
||||
CoinActionButton(
|
||||
icon: CakeImageWidget(
|
||||
imageUrl: "assets/new-ui/exchange.svg",
|
||||
colorFilter: ColorFilter.mode(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
label: S.of(context).swap,
|
||||
action: () {
|
||||
final page = getIt.get<NewSwapPage>(param2: currency);
|
||||
showCupertinoModalBottomSheet(
|
||||
context: context,
|
||||
barrierColor: Colors.black.withAlpha(85),
|
||||
builder: (context) => Material(
|
||||
child: ModalNavigator(
|
||||
rootPage: page,
|
||||
parentContext: context,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
CoinActionButton(
|
||||
icon: CakeImageWidget(
|
||||
width: 36,
|
||||
height: 36,
|
||||
imageUrl: "assets/new-ui/favorite.svg",
|
||||
colorFilter: ColorFilter.mode(
|
||||
isFavorite
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
gradientColors:
|
||||
isFavorite ? [const Color(0xFFDF2626), const Color(0xFF980F0F)] : null,
|
||||
label: S.of(context).favorite,
|
||||
action: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void openBuySellPage(BuildContext context, BuySellPageMode mode) {
|
||||
Navigator.of(context).pop();
|
||||
final page = getIt.get<NewBuySellAmountPage>(
|
||||
param1: NewBuySellParams(mode: mode, initialCurrency: currency));
|
||||
showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
isScrollControlled: true,
|
||||
context: navigatorKey.currentContext!,
|
||||
builder: (modalContext) => ModalNavigator(
|
||||
rootPage: page,
|
||||
parentContext: modalContext,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get canOpenBuySell => getIt.get<AppStore>().wallet!.balance.containsKey(currency);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import "package:cake_wallet/new-ui/model/charts/price_data.dart";
|
||||
import "package:cake_wallet/new-ui/model/charts/util/price_change_direction.dart";
|
||||
import "package:fl_chart/fl_chart.dart";
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
class PriceChart extends StatelessWidget {
|
||||
const PriceChart({
|
||||
super.key,
|
||||
required this.height,
|
||||
required this.prices,
|
||||
required this.direction,
|
||||
required this.touchCallback,
|
||||
});
|
||||
|
||||
final List<PriceData> prices;
|
||||
final double height;
|
||||
final PriceChangeDirection direction;
|
||||
final Function(FlTouchEvent, LineTouchResponse?) touchCallback;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chartPoints = prices.map((entry) {
|
||||
final x = entry.time.millisecondsSinceEpoch.toDouble();
|
||||
final y = double.parse(entry.quote.toString());
|
||||
return FlSpot(x, y);
|
||||
}).toList();
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: const FlGridData(show: false),
|
||||
titlesData: const FlTitlesData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: chartPoints,
|
||||
gradient: LinearGradient(colors: [direction.color.withAlpha(25), direction.color]),
|
||||
barWidth: 1.5,
|
||||
isStrokeCapRound: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(show: false),
|
||||
),
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
enabled: true,
|
||||
getTouchLineStart: (barData, spotIndex) => -double.infinity,
|
||||
getTouchLineEnd: (barData, spotIndex) => double.infinity,
|
||||
getTouchedSpotIndicator: (barData, spotIndexes) => spotIndexes
|
||||
.map(
|
||||
(index) => TouchedSpotIndicatorData(
|
||||
FlLine(
|
||||
strokeWidth: 1,
|
||||
color: direction.color.withAlpha(80),
|
||||
dashArray: [4, 4],
|
||||
),
|
||||
const FlDotData(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (touchedSpots) => touchedSpots.map((spot) => null).toList(),
|
||||
),
|
||||
touchCallback: touchCallback,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
|
||||
import "package:cw_core/crypto_currency.dart";
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
class ChartViewCoinHeader extends StatelessWidget {
|
||||
const ChartViewCoinHeader({required this.currency, required this.isFavorite, super.key});
|
||||
|
||||
final CryptoCurrency currency;
|
||||
final bool isFavorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
mainAxisAlignment: isFavorite ? MainAxisAlignment.spaceBetween : MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 10,
|
||||
children: [
|
||||
if (isFavorite)
|
||||
CakeImageWidget(
|
||||
imageUrl: currency.iconPath ?? "",
|
||||
width: 30,
|
||||
height: 30,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 5,
|
||||
children: [
|
||||
Text(
|
||||
currency.fullName ?? currency.title,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(9999999),
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
currency.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isFavorite)
|
||||
CakeImageWidget(
|
||||
imageUrl: "assets/new-ui/favorite.svg",
|
||||
width: 16,
|
||||
height: 16,
|
||||
colorFilter:
|
||||
ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
class ChartViewPriceHeader extends StatelessWidget {
|
||||
const ChartViewPriceHeader({
|
||||
required this.price,
|
||||
required this.ticker,
|
||||
required this.highlight,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String price;
|
||||
final String ticker;
|
||||
final bool highlight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text(
|
||||
price,
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
color: highlight
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
ticker,
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
color: highlight
|
||||
? Theme.of(context).colorScheme.primary.withAlpha(128)
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import "package:cake_wallet/new-ui/model/charts/util/chart_range.dart";
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
class ChartRangeSelector extends StatelessWidget {
|
||||
const ChartRangeSelector({required this.selectedRange, required this.onRangeSelected, super.key});
|
||||
|
||||
final ChartRange selectedRange;
|
||||
final Function(ChartRange) onRangeSelected;
|
||||
|
||||
static const double optionSize = 36;
|
||||
static const double optionPadding = 24;
|
||||
static const Duration switchDuration = Duration(milliseconds: 250);
|
||||
|
||||
double pillPosition(int selectedIndex) => selectedIndex * (optionSize + optionPadding);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedIndex = ChartRange.ranges.indexOf(selectedRange);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
curve: Curves.easeOutCubic,
|
||||
left: pillPosition(selectedIndex),
|
||||
duration: switchDuration,
|
||||
child: Container(
|
||||
height: optionSize,
|
||||
width: optionSize,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.onSurface.withAlpha(25),
|
||||
borderRadius: BorderRadius.circular(999999),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: ChartRange.ranges.map((item) {
|
||||
final selected = selectedRange == item;
|
||||
final isFirst = ChartRange.ranges.indexOf(item) == 0;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
if (!selected) {
|
||||
onRangeSelected(item);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: optionPadding / 2,
|
||||
left: isFirst ? 0 : optionPadding / 2,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: optionSize,
|
||||
height: optionSize,
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: switchDuration,
|
||||
style: TextStyle(
|
||||
fontWeight: selected ? FontWeight.w400 : FontWeight.w500,
|
||||
color: selected
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: Center(child: Text(item.displayText)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
import 'dart:math';
|
||||
import "dart:math";
|
||||
|
||||
import 'package:cake_wallet/themes/core/theme_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import "package:cake_wallet/themes/core/theme_extension.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
|
||||
class CoinActionButton extends StatelessWidget {
|
||||
const CoinActionButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.action,
|
||||
super.key,
|
||||
this.gradientColors,
|
||||
});
|
||||
|
||||
final Widget icon;
|
||||
final String label;
|
||||
final VoidCallback action;
|
||||
final List<Color>? gradientColors;
|
||||
|
||||
static const sizeFactor = 0.16;
|
||||
|
||||
@@ -37,10 +39,11 @@ class CoinActionButton extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
context.customColors.cardGradientColorPrimary,
|
||||
context.customColors.cardGradientColorSecondary
|
||||
],
|
||||
colors: gradientColors ??
|
||||
[
|
||||
context.customColors.cardGradientColorPrimary,
|
||||
context.customColors.cardGradientColorSecondary,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
@@ -61,7 +64,7 @@ class CoinActionButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: ExcludeSemantics(
|
||||
child: Text(
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
|
||||
@@ -118,6 +118,7 @@ class CurrencyPickerArgs {
|
||||
const CurrencyPickerArgs({
|
||||
this.selected,
|
||||
required this.items,
|
||||
this.showStablesHeader = true,
|
||||
this.balanceByAsset,
|
||||
this.filterByNetwork,
|
||||
required this.onSelected,
|
||||
@@ -129,6 +130,7 @@ class CurrencyPickerArgs {
|
||||
final CryptoCurrency? selected;
|
||||
final List<CryptoCurrency> items;
|
||||
final WalletType? filterByNetwork;
|
||||
final bool showStablesHeader;
|
||||
final void Function(CryptoCurrency) onSelected;
|
||||
final String Function(CryptoCurrency) symbolResolver;
|
||||
final Map<CryptoCurrency, CurrencyPickerBalance>? balanceByAsset;
|
||||
|
||||
@@ -166,6 +166,7 @@ class _MultiNetworkCurrencyPickerState extends State<MultiNetworkCurrencyPicker>
|
||||
natives: _natives,
|
||||
selected: widget.args.selected,
|
||||
symbolResolver: widget.args.symbolResolver,
|
||||
showStablesHeader: widget.args.showStablesHeader,
|
||||
onSelect: _selectCurrency,
|
||||
onStablecoinTap: _onStablecoinPillTapped,
|
||||
),
|
||||
@@ -190,10 +191,12 @@ class _MultiNetworkPickerBody extends StatefulWidget {
|
||||
required this.symbolResolver,
|
||||
required this.onSelect,
|
||||
required this.onStablecoinTap,
|
||||
required this.showStablesHeader,
|
||||
});
|
||||
|
||||
final bool isSearching;
|
||||
final bool recentsLoaded;
|
||||
final bool showStablesHeader;
|
||||
final CryptoCurrency? selected;
|
||||
final List<CryptoCurrency> items;
|
||||
final List<CryptoCurrency> recents;
|
||||
@@ -363,7 +366,7 @@ class _MultiNetworkPickerBodyState extends State<_MultiNetworkPickerBody> {
|
||||
onTap: onSelect,
|
||||
),
|
||||
),
|
||||
if (stablecoins.isNotEmpty)
|
||||
if (stablecoins.isNotEmpty && widget.showStablesHeader)
|
||||
_PickerSection(
|
||||
title: S.of(context).picker_section_stablecoins,
|
||||
child: PillGrid(
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
|
||||
class LongPressMenuItem {
|
||||
final String label;
|
||||
final String iconPath;
|
||||
final VoidCallback onSelected;
|
||||
final Color? color;
|
||||
|
||||
LongPressMenuItem(
|
||||
{required this.label, required this.iconPath, required this.onSelected, this.color});
|
||||
}
|
||||
|
||||
class LongPressMenu extends StatefulWidget {
|
||||
const LongPressMenu({super.key, required this.items});
|
||||
|
||||
final List<LongPressMenuItem> items;
|
||||
|
||||
@override
|
||||
State<LongPressMenu> createState() => _LongPressMenuState();
|
||||
}
|
||||
|
||||
class _LongPressMenuState extends State<LongPressMenu> {
|
||||
bool _isVisible = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() {
|
||||
_isVisible = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedSize(
|
||||
duration: Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox(
|
||||
height: _isVisible ? null : 0,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: Theme.of(context).colorScheme.primary.withAlpha(60),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: widget.items.map((item) {
|
||||
final color = item.color ?? Theme.of(context).colorScheme.onSurface;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: MergeSemantics(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
child: InkWell(
|
||||
onTap: item.onSelected,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
),
|
||||
child: Container(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
ExcludeSemantics(
|
||||
child: CakeImageWidget(
|
||||
imageUrl: item.iconPath,
|
||||
height: 20,
|
||||
width: 20,
|
||||
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
|
||||
),
|
||||
),
|
||||
Text(item.label,
|
||||
style: TextStyle(
|
||||
color: color, fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
class LongPressFooter extends StatelessWidget {
|
||||
const LongPressFooter({required this.text, super.key});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: Theme.of(context).colorScheme.primary.withAlpha(60),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 12,
|
||||
children: [
|
||||
CakeImageWidget(
|
||||
imageUrl: "assets/new-ui/info.svg",
|
||||
width: 18,
|
||||
height: 18,
|
||||
colorFilter: ColorFilter.mode(
|
||||
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import "dart:ui";
|
||||
|
||||
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
class LongPressMenuItem {
|
||||
LongPressMenuItem({
|
||||
required this.label,
|
||||
required this.iconPath,
|
||||
required this.onSelected,
|
||||
this.color,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String iconPath;
|
||||
final VoidCallback onSelected;
|
||||
final Color? color;
|
||||
}
|
||||
|
||||
class LongPressMenu extends StatefulWidget {
|
||||
const LongPressMenu({required this.items, super.key});
|
||||
|
||||
final List<LongPressMenuItem> items;
|
||||
|
||||
@override
|
||||
State<LongPressMenu> createState() => _LongPressMenuState();
|
||||
}
|
||||
|
||||
class _LongPressMenuState extends State<LongPressMenu> {
|
||||
bool _isVisible = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() {
|
||||
_isVisible = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox(
|
||||
height: _isVisible ? null : 0,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: Theme.of(context).colorScheme.primary.withAlpha(60),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: widget.items.map((item) {
|
||||
final color = item.color ?? Theme.of(context).colorScheme.onSurface;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: MergeSemantics(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
child: InkWell(
|
||||
onTap: item.onSelected,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
ExcludeSemantics(
|
||||
child: CakeImageWidget(
|
||||
imageUrl: item.iconPath,
|
||||
height: 20,
|
||||
width: 20,
|
||||
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
item.label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import "dart:ui";
|
||||
|
||||
import "package:cake_wallet/utils/show_pop_up.dart";
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
class LongPressPopupBuilder extends StatelessWidget {
|
||||
const LongPressPopupBuilder({
|
||||
required this.child,
|
||||
required this.popup,
|
||||
super.key,
|
||||
this.spacing = 8,
|
||||
this.showOnTap = false,
|
||||
this.footer,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Widget popup;
|
||||
final Widget? footer;
|
||||
final double spacing;
|
||||
final bool showOnTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onLongPress: () => _showMenu(context),
|
||||
onTap: showOnTap ? () => _showMenu(context) : null,
|
||||
child: IgnorePointer(ignoring: showOnTap, child: child),
|
||||
);
|
||||
|
||||
void _showMenu(BuildContext context) {
|
||||
final RenderBox renderBox = context.findRenderObject()! as RenderBox;
|
||||
final offset = renderBox.localToGlobal(Offset.zero);
|
||||
final size = renderBox.size;
|
||||
|
||||
showPopUp(
|
||||
context: context,
|
||||
builder: (context) => _buildPopup(context, offset, size),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPopup(BuildContext context, Offset offset, Size size) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
|
||||
final bool isOnRightHalf = offset.dx + (size.width / 2) > screenWidth / 2;
|
||||
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: offset.dx,
|
||||
top: offset.dy,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: isOnRightHalf ? null : offset.dx,
|
||||
right: isOnRightHalf ? (screenWidth - (offset.dx + size.width)) : null,
|
||||
top: offset.dy + size.height + spacing,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: popup,
|
||||
),
|
||||
),
|
||||
if (footer != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: MediaQuery.of(context).viewPadding.bottom,
|
||||
child: footer!,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -663,7 +663,7 @@ Route<dynamic> createRoute(RouteSettings settings) {
|
||||
|
||||
case Routes.buySellPage:
|
||||
return handleRouteWithPlatformAwareness(
|
||||
(context) => Material(child: getIt.get<NewBuySellAmountPage>(param1: BuySellPageMode.buy)),
|
||||
(context) => Material(child: getIt.get<NewBuySellAmountPage>(param1: NewBuySellParams(mode: BuySellPageMode.buy))),
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class _NEWNewMainNavBarState extends State<NewMainNavBar> {
|
||||
static const iconColorChangeDuration = Duration(milliseconds: 200);
|
||||
|
||||
static const pillTextStyle = TextStyle(
|
||||
fontSize: 14,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:cake_wallet/core/wallet_change_listener_view_model.dart';
|
||||
import 'package:cake_wallet/entities/fiat_currency.dart';
|
||||
import 'package:cake_wallet/entities/provider_types.dart';
|
||||
import 'package:cake_wallet/generated/i18n.dart';
|
||||
import "package:cake_wallet/new-ui/pages/buy_sell/buy_sell_amount_page.dart";
|
||||
import 'package:cake_wallet/new-ui/widgets/buy_sell/buy_sell_selector_modal.dart';
|
||||
import 'package:cake_wallet/routes.dart';
|
||||
import 'package:cake_wallet/store/app_store.dart';
|
||||
@@ -30,9 +31,10 @@ enum BuySellPageMode { buy, sell }
|
||||
class BuySellViewModel = BuySellViewModelBase with _$BuySellViewModel;
|
||||
|
||||
abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with Store {
|
||||
BuySellViewModelBase(AppStore appStore, {required this.mode, required this.fiatConversionStore})
|
||||
BuySellViewModelBase(AppStore appStore, {required NewBuySellParams params, required this.fiatConversionStore})
|
||||
: _cryptoAmount = '',
|
||||
fiatAmount = '',
|
||||
mode = params.mode,
|
||||
cryptoCurrencyAddress = '',
|
||||
isCryptoCurrencyAddressEnabled = false,
|
||||
cryptoCurrencies = <CryptoCurrency>[],
|
||||
@@ -58,6 +60,10 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S
|
||||
_initialize();
|
||||
|
||||
isCryptoCurrencyAddressEnabled = !(cryptoCurrency == wallet.currency);
|
||||
|
||||
if(params.initialCurrency != null) {
|
||||
changeCryptoCurrency(currency: params.initialCurrency!);
|
||||
}
|
||||
}
|
||||
|
||||
late Timer bestRateSync;
|
||||
|
||||
Reference in New Issue
Block a user