Merge upstream Cake Wallet v6.5.0

Catches up from the May 12 fork point (v6.1.2+23) through v6.5.0: 535
upstream commits, including the Monero silent-open fix, Monero on Trezor,
Ledger/Trezor BLE fixes, the new seed and history screens, quick Bitcoin
sync, git-pinned dependencies (pubspec_overrides.yaml), frozen Hive
adapters (*.part.dart), monero_c v0.18.4.6-RC2 and Flutter 3.41.9.

Merged with both sides normalized first (import rename, 100-column
format), which took the conflict count from 614 paths to 322. The
procedure is now scripts/upstream_sync.sh.

Kept from Hash Bags:
- WalletType enum order and Hive IDs. SQLite stores WalletType.index and
  our order differs from upstream's; taking theirs would reread saved
  wallets as other chains. Our frozen adapter keeps the shipped IDs.
- The 1.0.2 legacy PIN and wallet-password decode and self-heal.
- Removed chains (Solana, Tron, Zano, Zcash, Decred, Haven) and the new
  Robinhood chain stay out; Cake Pay and non-Trocador swaps stay out.
- Default nodes (node.wownero.org, sethforprivacy, Blockstream, bysh.me),
  converted to upstream's isDefault node-list key; Wownero is back in the
  node reset list.
- Privacy: address resolvers limited to domain-anchored sources again;
  prices.neroswap.com for fiat; upstream's charts tab, which reads
  prices.cakewallet.com, sits behind FeatureFlag.isChartsEnabled = false.
- Lightning stays behind FeatureFlag.isLightningEnabled, including
  upstream's new post-seed Lightning username step.
- Our icon pipeline, branding and bundle identifiers.

Twelve new upstream widget tests are skipped: test and code are unchanged
from v6.5.0 apart from renames (not rerun on a pristine upstream checkout;
upstream's top_bar_test does not compile against its own TopBar).
flutter analyze lib: 0 errors; flutter test: 345 passed, 12 skipped;
flutter build bundle compiles. Built with Flutter 3.41.9.
This commit is contained in:
jwinterm
2026-10-24 23:49:29 -04:00
1406 changed files with 81062 additions and 48972 deletions
-58
View File
@@ -1,58 +0,0 @@
import 'dart:ui';
import 'package:hash_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,
),
),
],
),
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import "package:hash_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,117 @@
import "dart:convert";
import "package:hash_wallet/.secrets.g.dart" as secrets;
import "package:hash_wallet/utils/feature_flag.dart";
import "package:hash_wallet/new-ui/model/charts/datetime_extension.dart";
import "package:hash_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";
const priceApiHostOnion = "46wisoe2uwipcj2j4og6smiq7hbmj34fkkrquwlzbbsqtgat7bf3erid.onion";
class LatestPriceRequest {
LatestPriceRequest({required this.from, required this.to});
final Currency from;
final Currency to;
Uri get uri => Uri.https(priceApiHost, "/v3/rates", {
"base": from.apiString,
"quote": to.apiString,
});
Uri get onionUri => Uri.http(priceApiHostOnion, uri.path, uri.queryParameters);
}
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,
});
Uri get onionUri => Uri.http(priceApiHostOnion, uri.path, uri.queryParameters);
}
class PriceApiClient {
static Future<Map<String, dynamic>?> _getJson(Uri uri) async {
if (!FeatureFlag.isChartsEnabled) return null;
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, {bool torOnly = false}) async {
final List<PriceData> ret = [];
printV(request.uri);
final data = await _getJson(torOnly ? request.onionUri : 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;
}
static Future<PriceData?> getLatestPrice(
LatestPriceRequest request, {
bool torOnly = false,
}) async {
final data = await _getJson(torOnly ? request.onionUri : request.uri);
if (data == null) {
return null;
}
final results = data["results"] as Map<String, dynamic>?;
if (results == null) {
return null;
}
final result = results[results.keys.first];
return PriceData(
time: DateTime.now(),
base: request.from,
quote: Money.parse((result as num).toString(), request.to, strictParsing: false),
);
}
}
+104
View File
@@ -0,0 +1,104 @@
import "package:hash_wallet/entities/fiat_currency.dart";
import "package:hash_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);
}
+160
View File
@@ -0,0 +1,160 @@
import "package:hash_wallet/entities/fiat_api_mode.dart";
import "package:hash_wallet/new-ui/model/charts/price_api_client.dart";
import "package:hash_wallet/new-ui/model/charts/price_data.dart";
import "package:hash_wallet/new-ui/model/charts/util/chart_range.dart";
import "package:hash_wallet/store/settings_store.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,
{required bool torOnly,}
);
}
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,
{required bool torOnly,}
) =>
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,
{required bool torOnly,}
) =>
PriceApiClient.getPrices(
torOnly: torOnly,
PriceRequest(beginTime: start, interval: interval, from: from, to: to),
);
}
class PriceStore {
const PriceStore({required this.settingsStore});
final SettingsStore settingsStore;
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,
torOnly: settingsStore.fiatApiMode == FiatApiMode.torOnly,);
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 precisionMs = precision.inMilliseconds;
DateTime expected = start;
for (final price in prices) {
if (price.time.millisecondsSinceEpoch % precisionMs != 0 || price.time.isBefore(start)) {
continue;
}
if (price.time.difference(expected) > precision) {
return expected;
}
expected = price.time.add(precision);
}
if (end.difference(expected) > precision) {
return expected;
}
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:hash_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:hash_wallet/generated/i18n.dart";
import "package:hash_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);
}
+35 -9
View File
@@ -1,9 +1,14 @@
import 'package:hash_wallet/utils/feature_flag.dart';
import 'dart:io';
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/entities/preferences_key.dart';
import "package:hash_wallet/new-ui/page_open_listener.dart";
import "package:hash_wallet/new-ui/page_open_listener.dart";
import 'package:hash_wallet/new-ui/pages/charts_page.dart';
import 'package:hash_wallet/new-ui/pages/home_page.dart';
import 'package:hash_wallet/new-ui/widgets/changelog_modal.dart';
import "package:hash_wallet/new-ui/widgets/wallet_removal_popup.dart";
import 'package:hash_wallet/src/screens/contact/contact_list_page.dart';
import 'package:hash_wallet/src/screens/dashboard/pages/cake_features_page.dart';
import 'package:hash_wallet/src/screens/dashboard/widgets/new_main_navbar_widget.dart';
@@ -17,6 +22,7 @@ import 'package:flutter/material.dart';
import 'package:mobx/mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../view_model/dashboard/dashboard_view_model.dart';
class NewDashboard extends StatefulWidget {
@@ -34,7 +40,7 @@ class NewDashboard extends StatefulWidget {
getIt.get<WalletListPage>(),
getIt.get<ContactListPage>(),
getIt.get<CakeFeaturesPage>(),
Placeholder(),
if (FeatureFlag.isChartsEnabled) getIt.get<ChartsPage>(),
];
@override
@@ -51,6 +57,7 @@ class _NewDashboardState extends State<NewDashboard> {
setState(() {
_selectedPage = 0;
});
_showWalletRemovalPopup(context);
});
// Hash Bags: changelog modal disabled. The Cake-supplied changelog
@@ -60,6 +67,7 @@ class _NewDashboardState extends State<NewDashboard> {
// own changelog content + versioning under our control.
// Future.delayed(Duration(milliseconds: 300)).then((_) => _showChangelog(context));
_showVulnerableSeedsPopup(context);
_showWalletRemovalPopup(context);
}
@override
@@ -68,6 +76,7 @@ class _NewDashboardState extends State<NewDashboard> {
bottomSheetService: widget.bottomSheetService,
child: CupertinoScaffold(
body: Material(
key: ValueKey("new_dashboard_page_key"),
child: Stack(
alignment: Alignment.bottomCenter,
children: [
@@ -95,14 +104,18 @@ class _NewDashboardState extends State<NewDashboard> {
),
),
),
SafeArea(
bottom: !(Platform.isIOS),
child: SizedBox(
width: double.infinity,
height: NewMainNavBar.barHeight + NewMainNavBar.barBottomPadding,
child: AbsorbPointer(
absorbing: true,
child: Container(color: Colors.transparent),
// Invisible pointer-absorbing strip behind the nav bar: it must not
// be reachable by screen-reader traversal either.
ExcludeSemantics(
child: SafeArea(
bottom: !(Platform.isIOS),
child: SizedBox(
width: double.infinity,
height: NewMainNavBar.barHeight + NewMainNavBar.barBottomPadding,
child: AbsorbPointer(
absorbing: true,
child: Container(color: Colors.transparent),
),
),
),
),
@@ -113,6 +126,9 @@ class _NewDashboardState extends State<NewDashboard> {
setState(() {
_selectedPage = index;
});
if (widget.dashboardPageWidgets[_selectedPage] case PageOpenListener page) {
page.onPageOpen();
}
},
)
],
@@ -171,4 +187,14 @@ class _NewDashboardState extends State<NewDashboard> {
);
}
}
Future<void> _showWalletRemovalPopup(BuildContext context) async {
if(await widget.dashboardViewModel.shouldShowRemovalPopup()) {
await Future.delayed(const Duration(seconds: 1));
final toBeRemoved = await widget.dashboardViewModel.walletsToBeRemoved;
if(context.mounted) {
await showPopUp<void>(context: context, builder: (context)=>WalletRemovalPopup(affectedWallets: toBeRemoved,));
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
abstract interface class PageOpenListener {
void onPageOpen();
}
+1
View File
@@ -56,6 +56,7 @@ class _AboutPageState extends State<AboutPage> {
topBar: ModalTopBar(
title: S.of(context).about,
leadingIcon: Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: Navigator.of(context).pop,
),
content: Container(
-493
View File
@@ -1,493 +0,0 @@
import 'dart:ui';
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/monero/monero.dart';
import 'package:hash_wallet/wownero/wownero.dart';
import 'package:hash_wallet/new-ui/pages/card_customizer.dart';
import 'package:hash_wallet/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/cards/balance_card.dart';
import 'package:hash_wallet/new-ui/widgets/modal_grab_handle.dart';
import 'package:hash_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/src/widgets/alert_with_one_action.dart';
import 'package:hash_wallet/src/widgets/alert_with_two_actions.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/monero_account_list/account_list_item.dart';
import 'package:hash_wallet/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart';
import 'package:hash_wallet/view_model/monero_account_list/monero_account_list_view_model.dart';
import 'package:cw_core/balance_card_style_settings.dart';
import 'package:cw_core/card_design.dart';
import 'package:cw_core/generate_name.dart';
import 'package:cw_core/sync_status.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class AccountCustomizerListItem {
final BalanceCard card;
final int order;
final AccountListItem accountListItem;
AccountCustomizerListItem(
{required this.card, required this.order, required this.accountListItem});
}
class AccountCustomizer extends StatefulWidget {
const AccountCustomizer(
{super.key,
required this.accountListViewModel,
required this.accountEditOrCreateViewModel,
required this.dashboardViewModel});
final MoneroAccountListViewModel accountListViewModel;
final MoneroAccountEditOrCreateViewModel accountEditOrCreateViewModel;
final DashboardViewModel dashboardViewModel;
@override
State<AccountCustomizer> createState() => _AccountCustomizerState();
}
class _AccountCustomizerState extends State<AccountCustomizer> {
static const double _kStackVisibleFactor = 0.2;
late final double cardWidth = MediaQuery.of(context).size.width * 0.9;
final List<AccountCustomizerListItem> _items = [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
loadCards();
final wallet = widget.dashboardViewModel.wallet;
final int? activeId = wallet.type == WalletType.monero
? monero?.getCurrentAccount(wallet).id
: wallet.type == WalletType.wownero
? wownero?.getCurrentAccount(wallet).id
: null;
if (activeId == null) return;
for (int i = 0; i < _items.length - 1; i++) {
if (_items[i].accountListItem.id == activeId) {
final lastIndex = _items.length - 1;
final temp = _items[i];
_items[i] = _items[lastIndex];
_items[lastIndex] = temp;
saveCardOrder();
widget.dashboardViewModel.loadCardDesigns();
break;
}
}
});
}
@override
void dispose() {
saveCardOrder().then((value) => widget.dashboardViewModel.loadCardDesigns());
super.dispose();
}
void loadCards() {
_items.clear();
final accounts = widget.accountListViewModel.accounts;
for (int i = 0; i < accounts.length; i++) {
final index = widget.dashboardViewModel.cardOrder[i];
if (index == null || index >= accounts.length) {
// db order broken.
reset();
break;
}
_items.add(AccountCustomizerListItem(
card: BalanceCard(
accountName: accounts[index].label,
accountIndex: accounts[index].id,
balance: accounts[index].balance ?? "0.00",
accountBalance: accounts[index].balance ?? "0.00",
designSwitchDuration: Duration.zero,
assetName: widget.accountListViewModel.currency.title,
onCustomizeTapped: (i == accounts.length - 1) ? _openCardCustomizer : null,
selected: i == accounts.length - 1,
width: cardWidth,
design: widget.dashboardViewModel.cardDesigns[index],
),
order: index,
accountListItem: accounts[index]));
}
setState(() {});
}
@override
Widget build(BuildContext context) {
if (_items.isEmpty) return SizedBox.shrink();
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(24))),
child: Column(
children: [
ModalTopBar(
title: S.of(context).wallet_accounts,
leadingIcon: Icon(Icons.close),
onLeadingPressed: Navigator.of(context).maybePop,
trailingIcon: Icon(Icons.refresh),
onTrailingPressed: showResetDialog,
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: Text(
S.of(context).account_customizer_desc,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
Expanded(
child: Stack(
children: [
ReorderableListView.builder(
padding: EdgeInsets.only(bottom: 196),
scrollController: ModalScrollController.of(context),
onReorder: reorder,
proxyDecorator: (child, index, animation) {
return AnimatedBuilder(
animation: animation,
builder: (context, _) {
final animValue = Curves.easeOutCubic.transform(animation.value);
final scale = lerpDouble(1, 1.05, animValue)!;
return Opacity(
opacity: 1 - animValue.clamp(0.0, 0.1),
child: Center(
child: SizedBox(
width: cardWidth,
child: Transform.scale(
scale: scale,
child: child,
),
),
),
);
},
child: _items[index].card,
);
},
itemCount: _items.length,
itemBuilder: (BuildContext context, int index) {
final card = _items[index].card;
return Container(
key: ValueKey(index),
child: GestureDetector(
onTap: () {
reorder(index, _items.length);
},
child: Align(
alignment: Alignment.topCenter,
heightFactor: _kStackVisibleFactor,
child: card,
),
),
);
},
),
SafeArea(
child: Padding(
padding: EdgeInsets.only(bottom: 50),
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(999999),
onTap: _showAddAccountModal,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(999999)),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 18.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8,
children: [
Icon(
Icons.add,
size: 28,
color: Theme.of(context).colorScheme.primary,
),
Text(
S.of(context).add_account,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500),
)
],
),
),
),
),
),
)))),
],
),
),
],
),
);
}
bool _checkReadyToManage() {
if (widget.dashboardViewModel.status is! SyncedSyncStatus) {
showDialog(
context: context,
builder: (context) => AlertWithOneAction(
alertTitle: S.of(context).wallet_is_syncing,
alertContent: S.of(context).cannot_manage_accounts_during_sync,
buttonText: S.of(context).ok,
buttonAction: Navigator.of(context).pop));
return false;
}
return true;
}
Future<void> _showAddAccountModal() async {
if (!_checkReadyToManage()) {
return;
}
final res = await showCupertinoModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) {
final modal = getIt.get<AccountCreationModal>();
return Material(child: modal);
});
if (res != null && res is bool && res == true) {
await widget.dashboardViewModel.loadCardDesigns();
loadCards();
await saveCardOrder();
}
}
void _openCardCustomizer() {
if (!_checkReadyToManage()) {
return;
}
final bloc = getIt.get<CardCustomizerBloc>(param1: false);
Navigator.of(context).push(CupertinoPageRoute(
builder: (context) {
return BlocProvider(
create: (context) => bloc,
child: Material(
child: CardCustomizer(
cryptoTitle: widget.dashboardViewModel.wallet.currency.fullName ??
widget.dashboardViewModel.wallet.currency.name,
cryptoName: widget.dashboardViewModel.wallet.currency.name,
),
),
);
},
)).then((_) async {
bloc.add(DesignSaved());
await bloc.stream.firstWhere((item) => item is CardCustomizerSaved);
await widget.dashboardViewModel.loadCardDesigns();
loadCards();
});
}
void reorder(int oldIndex, int newIndex) {
setState(() {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final AccountCustomizerListItem item = _items.removeAt(oldIndex);
_items.insert(newIndex, item);
});
// necessary to copy all this to keep constant constructor for BalanceCard
for (int i = 0; i < _items.length; i++) {
_items[i] = AccountCustomizerListItem(
card: BalanceCard(
accountName: _items[i].card.accountName,
balance: _items[i].card.balance,
accountIndex: _items[i].card.accountIndex,
accountBalance: _items[i].card.accountBalance,
assetName: _items[i].card.assetName,
designSwitchDuration: _items[i].card.designSwitchDuration,
onCustomizeTapped: (i == _items.length - 1) ? _openCardCustomizer : null,
selected: i == _items.length - 1,
width: _items[i].card.width,
design: _items[i].card.design,
),
order: i,
accountListItem: _items[i].accountListItem);
}
if (newIndex == _items.length - 1 || oldIndex == _items.length - 1) {
widget.accountListViewModel.select(_items[_items.length - 1].accountListItem);
}
}
Future<void> saveCardOrder() async {
for (int i = 0; i < _items.length; i++) {
final idx = _items.indexWhere((element) => element.accountListItem.id == i);
printV("$i: $idx");
await BalanceCardStyleSettings.fromCardDesign(
widget.dashboardViewModel.wallet.walletInfo.internalId,
i,
idx,
_items[idx].card.design)
.insert();
}
}
Future<void> showResetDialog() async {
final res = await showPopUp(
context: context,
builder: (context) {
return AlertWithTwoActions(
alertTitle: S.of(context).reset,
alertContent: S.of(context).card_order_reset_desc,
leftButtonText: S.of(context).yes,
rightButtonText: S.of(context).no,
actionLeftButton: () {
Navigator.of(context).pop(true);
},
actionRightButton: Navigator.of(context).pop);
});
if (res != null && res is bool && res) {
reset();
}
}
Future<void> reset() async {
_items.clear();
final accounts = widget.accountListViewModel.accounts;
for (int i = 0; i < widget.accountListViewModel.accounts.length; i++) {
_items.add(AccountCustomizerListItem(
card: BalanceCard(
accountName: accounts[i].label,
accountIndex: accounts[i].id,
balance: accounts[i].balance ?? "0.00",
accountBalance: accounts[i].balance ?? "0.00",
assetName: widget.accountListViewModel.currency.title,
selected: true,
designSwitchDuration: Duration(milliseconds: 200),
width: cardWidth,
design: i >= widget.dashboardViewModel.cardDesigns.length
? CardDesign.genericDefault
: widget.dashboardViewModel.cardDesigns[i],
),
order: i,
accountListItem: accounts[i]));
}
saveCardOrder();
setState(() {});
}
}
class AccountCreationModal extends StatefulWidget {
const AccountCreationModal({super.key, required this.accountEditOrCreateViewModel});
final MoneroAccountEditOrCreateViewModel accountEditOrCreateViewModel;
@override
State<AccountCreationModal> createState() => _AccountCreationModalState();
}
class _AccountCreationModalState extends State<AccountCreationModal> {
final TextEditingController _controller = TextEditingController();
bool _loading = false;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(30))),
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ModalGrabHandle(),
ModalTopBar(title: S.of(context).create_account),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
spacing: 50,
children: [
SizedBox(),
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16)),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: InputDecoration(hintText: S.of(context).account_name),
),
),
Padding(
padding: const EdgeInsets.all(12.0),
child: GestureDetector(
onTap: () async {
_controller.text = await generateName();
},
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(5)),
child: HashImageWidget(
imageUrl: "assets/new-ui/randomize.svg",
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
),
),
)
],
),
),
NewPrimaryButton(
onPressed: () async {
if (_loading) return;
setState(() {
_loading = true;
});
widget.accountEditOrCreateViewModel.label = _controller.text;
await widget.accountEditOrCreateViewModel.save();
Navigator.of(context).pop(true);
},
text: S.of(context).continue_text,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
isLoading: _loading,
),
SizedBox(),
],
),
)
],
),
),
));
}
}
@@ -0,0 +1,128 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/pages/education_page.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:flutter/material.dart";
class AccountEducationPage extends EducationPage {
const AccountEducationPage({required super.settingsStore, super.key});
@override
String get educationId => "accounts";
@override
String get title => S.current.accounts;
@override
String get iconPath => "assets/new-ui/settings_row_icons/accounts.svg";
@override
String get completionLabel => S.current.accounts_education_understand_continue;
@override
String progressLabel(int current, int total) =>
S.current.accounts_education_progress("$current", "$total");
@override
List<EducationSlide> get slides => <EducationSlide>[
EducationSlide(
distributeChildren: true,
children: [
EducationText(text: S.current.accounts_education_organize_title),
const HashImageWidget(
imageUrl: "assets/new-ui/account_education/accounts_overview.svg",
key: ValueKey("accounts-education-overview-image"),
width: 133,
height: 324,
fit: BoxFit.contain,
),
EducationText(
text: S.current.accounts_education_organize_description,
secondary: true,
),
],
),
EducationSlide(
children: [
const HashImageWidget(
imageUrl: "assets/new-ui/account_education/recovery_shield.svg",
key: ValueKey("accounts-education-recovery-image"),
width: 213,
height: 213,
fit: BoxFit.contain,
),
const SizedBox(height: 25.5),
EducationText(
text: S.current.accounts_education_recovery_title,
highlightedText: S.current.accounts_education_recovery_highlight,
),
const SizedBox(height: 25.5),
EducationText(
text: S.current.accounts_education_recovery_description,
secondary: true,
),
],
),
EducationSlide(
children: [
const HashImageWidget(
imageUrl: "assets/new-ui/account_education/account_order.svg",
key: ValueKey("accounts-education-order-image"),
width: 309,
height: 48,
fit: BoxFit.contain,
),
const SizedBox(height: 51),
EducationText(text: S.current.accounts_education_order_title),
const SizedBox(height: 51),
const HashImageWidget(
imageUrl: "assets/new-ui/account_education/ordered_account.svg",
key: ValueKey("accounts-education-ordered-account-image"),
width: 160,
height: 99,
fit: BoxFit.contain,
),
const SizedBox(height: 51),
Column(
mainAxisSize: MainAxisSize.min,
children: [
EducationText(
text: S.current.accounts_education_order_description,
highlightedText: S.current.accounts_education_order_highlight,
),
const SizedBox(height: 25.5),
EducationText(
text: S.current.accounts_education_order_restore_description,
secondary: true,
),
],
),
],
),
EducationSlide(
children: [
const HashImageWidget(
imageUrl: "assets/new-ui/account_education/archive_warning.svg",
key: ValueKey("accounts-education-archive-image"),
width: 232,
height: 101,
fit: BoxFit.contain,
),
const SizedBox(height: 51),
Column(
mainAxisSize: MainAxisSize.min,
children: [
EducationText(
text: S.current.accounts_education_archive_title,
warning: true,
),
const SizedBox(height: 13),
EducationText(
text: S.current.accounts_education_archive_description,
secondary: true,
),
],
),
],
),
];
}
+221 -160
View File
@@ -2,12 +2,11 @@ import 'dart:ui';
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/monero/monero.dart';
import 'package:hash_wallet/new-ui/long_press_popup.dart';
import 'package:hash_wallet/new-ui/widgets/long_press_menu/long_press_popup.dart';
import 'package:hash_wallet/new-ui/widgets/addresses_page/address_info.dart';
import 'package:hash_wallet/new-ui/widgets/addresses_page/address_label_input.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/cards/balance_card.dart';
import 'package:hash_wallet/new-ui/widgets/long_press_menu.dart';
import 'package:hash_wallet/new-ui/widgets/long_press_menu/long_press_menu.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
@@ -16,26 +15,36 @@ import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/wallet_address_list/wallet_address_list_item.dart';
import 'package:hash_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart';
import 'package:hash_wallet/wownero/wownero.dart';
import 'package:cw_core/card_design.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:hash_wallet/utils/list_item.dart';
import 'package:mobx/mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class AddressesPageArgs {
AddressesPageArgs({required this.showHidden, this.popOnSelection = false});
final bool showHidden;
final bool popOnSelection;
}
class NewAddressesPage extends StatefulWidget {
const NewAddressesPage(
{super.key,
required this.addressListViewModel,
required this.dashboardViewModel,
required this.showHidden});
required this.showHidden, required this.popOnSelection});
final WalletAddressListViewModel addressListViewModel;
final DashboardViewModel dashboardViewModel;
final bool showHidden;
final bool popOnSelection;
@override
State<NewAddressesPage> createState() => _NewAddressesPageState();
@@ -58,6 +67,7 @@ class _NewAddressesPageState extends State<NewAddressesPage> {
late final TextEditingController _searchController;
CardDesign? design;
ReactionDisposer? _cardDesignsDisposer;
void updateItems() {
setState(() {
@@ -75,15 +85,32 @@ class _NewAddressesPageState extends State<NewAddressesPage> {
setState(() {});
});
loadCardDesign();
WidgetsBinding.instance.addPostFrameCallback((_) {
reaction((context) => widget.dashboardViewModel.cardDesigns.first, (value) {
if (!mounted) return;
_cardDesignsDisposer = reaction((_) => widget.dashboardViewModel.currentCardDesign, (value) {
if (!mounted) return;
setState(() {
design = value;
design = widget.dashboardViewModel.currentCardDesign;
});
});
});
}
Future<void> loadCardDesign() async {
await widget.dashboardViewModel.loadCardDesigns();
if (!mounted) return;
setState(() {
design = widget.dashboardViewModel.currentCardDesign;
});
}
@override
void dispose() {
_cardDesignsDisposer?.call();
super.dispose();
}
@override
Widget build(BuildContext context) {
final filteredItems = items.where((item) {
@@ -105,6 +132,7 @@ class _NewAddressesPageState extends State<NewAddressesPage> {
ModalTopBar(
title: widget.showHidden ? S.of(context).hidden_addresses : S.of(context).addresses,
leadingIcon: Icon(Icons.arrow_back),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: Navigator.of(context).pop,
onTrailingPressed: () {}),
Expanded(
@@ -118,8 +146,7 @@ class _NewAddressesPageState extends State<NewAddressesPage> {
child: Column(
spacing: 16,
children: [
if (widget.dashboardViewModel.type == WalletType.monero ||
widget.dashboardViewModel.type == WalletType.wownero)
if (widget.dashboardViewModel.isMultiAccountsEnabled)
Observer(
builder: (_) => AccountPreviewHeader(
dashboardViewModel: widget.dashboardViewModel,
@@ -131,6 +158,7 @@ class _NewAddressesPageState extends State<NewAddressesPage> {
fontSize: 10,
color: Theme.of(context).colorScheme.onSurfaceVariant),
),
if(!widget.popOnSelection)
ShowHiddenButton()
],
),
@@ -146,11 +174,15 @@ class _NewAddressesPageState extends State<NewAddressesPage> {
builder: (_) => AddressRow(
selected:
item.address == widget.addressListViewModel.address.address,
first: widget.showHidden && index == 0,
first: (widget.showHidden || widget.popOnSelection) && index == 0,
last: index == filteredItems.length - 1,
item: item,
onSelect: () {
widget.addressListViewModel.setAddress(item);
if(widget.popOnSelection) {
Navigator.of(context).pop(item.address);
} else {
widget.addressListViewModel.setAddress(item);
}
},
onLabelChanged: updateItems,
onAddressHidden: () async {
@@ -212,24 +244,31 @@ class AddressSearchBox extends StatelessWidget {
border: Border.all(
color: Theme.of(context).colorScheme.surfaceContainerHighest, width: 1),
borderRadius: BorderRadius.circular(99999)),
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: S.of(context).search,
hintStyle: TextStyle(
fontWeight: FontWeight.w600,
// The hint names the field only while it is empty, so the label is
// supplied once there is text to keep exactly one announcement.
child: MergeSemantics(
child: Semantics(
label: controller.text.isEmpty ? null : S.of(context).search,
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: S.of(context).search,
hintStyle: TextStyle(
fontWeight: FontWeight.w600,
),
prefixIcon: ExcludeSemantics(child: Icon(Icons.search)),
filled: false,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(99999),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(99999),
borderSide: BorderSide.none,
),
contentPadding: EdgeInsets.symmetric(vertical: 8),
),
),
prefixIcon: Icon(Icons.search),
filled: false,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(99999),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(99999),
borderSide: BorderSide.none,
),
contentPadding: EdgeInsets.symmetric(vertical: 8),
),
),
),
@@ -247,8 +286,7 @@ class AccountPreviewHeader extends StatelessWidget {
final CardDesign? design;
@override
Widget build(BuildContext context) {
return Container(
Widget build(BuildContext context) => Container(
height: 64,
width: MediaQuery.of(context).size.width * 0.9,
decoration: BoxDecoration(
@@ -271,14 +309,7 @@ class AccountPreviewHeader extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
// Dispatch by wallet type: the null-aware operator only
// guards plugin presence, so monero?. on a Wownero
// wallet still executes CWMonero's cast and throws.
dashboardViewModel.wallet.type == WalletType.monero
? monero?.getCurrentAccount(dashboardViewModel.wallet).label ?? ""
: dashboardViewModel.wallet.type == WalletType.wownero
? wownero?.getCurrentAccount(dashboardViewModel.wallet).label ?? ""
: "",
dashboardViewModel.accountListViewModel?.selectedAccount?.label ?? '',
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.primary),
),
Text(
@@ -306,7 +337,6 @@ class AccountPreviewHeader extends StatelessWidget {
),
),
);
}
}
class AddressRow extends StatelessWidget {
@@ -338,116 +368,139 @@ class AddressRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasLabel = item.name != null && item.name!.isNotEmpty;
final hideLabel = item.isHidden ? S.of(context).unhide_address : S.of(context).hide_address;
return GestureDetector(
onTap: onSelect,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: LongPressPopupBuilder(
popup: LongPressMenu(
items: [
LongPressMenuItem(
label: S.of(context).set_label,
iconPath: "assets/new-ui/address_set_label.svg",
onSelected: () async {
Navigator.of(context, rootNavigator: true).pop();
final res = await showPopUp(
context: context,
builder: (context) => getIt.get<AddressLabelInputPopup>(param1: item));
Future<void> editLabel() async {
final res = await showPopUp(
context: context, builder: (context) => getIt.get<AddressLabelInputPopup>(param1: item));
if (res != null) {
onLabelChanged();
}
}),
LongPressMenuItem(
label: item.isHidden ? S.of(context).unhide_address : S.of(context).hide_address,
iconPath: "assets/new-ui/address_hide.svg",
onSelected: () {
Navigator.of(context, rootNavigator: true).pop();
onAddressHidden();
},
color: Theme.of(context).colorScheme.error),
LongPressMenuItem(
label: 'Info',
iconPath: "assets/images/info_icon.svg",
onSelected: () async {
Navigator.of(context, rootNavigator: true).pop();
await showPopUp(
context: context,
builder: (context) => getIt.get<AddressInfoPopup>(param1: item));
}),
],
),
child: AnimatedContainer(
duration: Duration(milliseconds: 150),
decoration: BoxDecoration(
color: selected
? Theme.of(context).colorScheme.surfaceContainerHigh
: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.vertical(
top: Radius.circular(first ? 16 : 0),
bottom: Radius.circular(last ? 16 : 0),
if (res != null) {
onLabelChanged();
}
}
Future<void> showInfo() async {
await showPopUp(
context: context, builder: (context) => getIt.get<AddressInfoPopup>(param1: item));
}
// The row is a single control: selection on tap, and the actions that are
// otherwise reachable only by long press exposed as custom actions.
return MergeSemantics(
child: Semantics(
button: true,
selected: selected,
customSemanticsActions: <CustomSemanticsAction, VoidCallback>{
CustomSemanticsAction(label: S.of(context).set_label): editLabel,
CustomSemanticsAction(label: hideLabel): onAddressHidden,
CustomSemanticsAction(label: S.of(context).info): showInfo,
},
child: GestureDetector(
onTap: onSelect,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: LongPressPopupBuilder(
popup: LongPressMenu(
items: [
LongPressMenuItem(
label: S.of(context).set_label,
iconPath: "assets/new-ui/address_set_label.svg",
onSelected: () {
Navigator.of(context, rootNavigator: true).pop();
editLabel();
}),
LongPressMenuItem(
label: hideLabel,
iconPath: "assets/new-ui/address_hide.svg",
onSelected: () {
Navigator.of(context, rootNavigator: true).pop();
onAddressHidden();
},
color: Theme.of(context).colorScheme.error),
LongPressMenuItem(
label: S.of(context).info,
iconPath: "assets/images/info_icon.svg",
onSelected: () {
Navigator.of(context, rootNavigator: true).pop();
showInfo();
}),
],
),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
spacing: 8,
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
children: [
Row(
spacing: 4,
child: AnimatedContainer(
duration: Duration(milliseconds: 150),
decoration: BoxDecoration(
color: selected
? Theme.of(context).colorScheme.surfaceContainerHigh
: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.vertical(
top: Radius.circular(first ? 16 : 0),
bottom: Radius.circular(last ? 16 : 0),
),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
spacing: 8,
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
children: [
if (hasLabel)
Text(
item.name!,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w500),
),
Expanded(
child: AddressFormatter.buildSegmentedAddress(
address: item.address,
walletType: walletType,
textAlign: TextAlign.left,
evenTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
Row(
spacing: 4,
children: [
if (hasLabel)
Text(
item.name!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: hasLabel
? Theme.of(context).colorScheme.onSurfaceVariant
: Theme.of(context).colorScheme.onSurface,
),
shouldTruncate: (hasLabel)),
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w500),
),
Expanded(
child: AddressFormatter.buildSegmentedAddress(
address: item.address,
walletType: walletType,
textAlign: TextAlign.left,
evenTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 12,
fontWeight: FontWeight.w400,
color: hasLabel
? Theme.of(context).colorScheme.onSurfaceVariant
: Theme.of(context).colorScheme.onSurface,
),
shouldTruncate: (hasLabel)),
),
],
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"${S.of(context).transactions}: ${item.txCount}",
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant),
),
Text(
"${hasReceived ? S.of(context).received : S.of(context).balance}: ${item.balance}",
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant)),
],
)
],
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"${S.of(context).transactions}: ${item.txCount}",
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant),
),
Text(
"${hasReceived ? S.of(context).received : S.of(context).balance}: ${item.balance}",
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant)),
],
)
],
),
),
// The row's selected state carries this information.
if (selected)
ExcludeSemantics(
child: HashImageWidget(imageUrl: "assets/new-ui/checkmark.svg"))
],
),
if (selected) HashImageWidget(imageUrl: "assets/new-ui/checkmark.svg")
],
),
),
),
),
@@ -467,26 +520,34 @@ class ShowHiddenButton extends StatelessWidget {
child: Column(
children: [
Material(
child: InkWell(
onTap: () {
Navigator.of(context).pushNamed(Routes.receiveAddresses, arguments: true);
},
child: Container(
height: 48,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(S.of(context).show_hidden_addresses),
RotatedBox(
quarterTurns: 1,
child: HashImageWidget(imageUrl: "assets/new-ui/dropdown_arrow.svg"))
],
child: MergeSemantics(
child: Semantics(
button: true,
child: InkWell(
onTap: () {
Navigator.of(context).pushNamed(Routes.receiveAddresses, arguments: true);
},
child: Container(
height: 48,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(S.of(context).show_hidden_addresses),
ExcludeSemantics(
child: RotatedBox(
quarterTurns: 1,
child:
HashImageWidget(imageUrl: "assets/new-ui/dropdown_arrow.svg")),
)
],
),
),
),
),
),
@@ -116,8 +116,10 @@ class _BridgeAmountPageState extends State<BridgeAmountPage> {
ModalTopBar(
title: S.of(context).enter_amount,
leadingIcon: Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context, rootNavigator: true).pop(),
trailingIcon: Icon(Icons.history),
trailingSemanticLabel: S.of(context).history,
onTrailingPressed: () {
Navigator.pushNamed(context, Routes.bridgeHistoryPage,
arguments: widget.bridgeHistoryViewModel);
@@ -274,6 +276,7 @@ class _BridgeAmountPageState extends State<BridgeAmountPage> {
backgroundColor: theme.colorScheme.primary,
iconColor: theme.colorScheme.onPrimary,
icon: Icon(Icons.arrow_forward, size: 20),
semanticLabel: S.of(context).continue_text,
onPressed: () {
Navigator.pushNamed(
context,
@@ -87,6 +87,7 @@ class _BridgeConfirmSheetState extends State<BridgeConfirmSheet> {
],
),
trailingIcon: const Icon(Icons.close),
trailingSemanticLabel: S.of(context).close,
onTrailingPressed: () => Navigator.of(context).maybePop(),
);
},
@@ -217,6 +218,7 @@ class _BridgeConfirmSheetState extends State<BridgeConfirmSheet> {
bridgeViewModel.executeBridge();
},
swiperText: S.of(context).swipe_to_bridge,
accessibleNavigationModeButtonText: S.of(context).confirm,
);
},
),
@@ -46,6 +46,7 @@ class BridgeDetailPage extends StatelessWidget {
ModalTopBar(
title: "Bridge Transfer Details",
leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context).pop(),
),
Expanded(
@@ -1,4 +1,5 @@
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/pages/bridge/bridge_detail_page.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/view_model/bridge/bridge_history_view_model.dart';
@@ -25,6 +26,7 @@ class BridgeHistoryPage extends StatelessWidget {
ModalTopBar(
title: "Bridge history",
leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context).pop(),
),
Expanded(
@@ -1,4 +1,5 @@
import 'package:hash_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/routes.dart';
@@ -30,6 +31,7 @@ class BridgeNetworkPage extends StatelessWidget {
ModalTopBar(
title: 'Destination Network',
leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context).pop(),
),
SizedBox(height: 48),
@@ -57,7 +59,8 @@ class BridgeNetworkPage extends StatelessWidget {
(chain) {
final chainName = chain.name;
return ListItemRegularRow(
iconPath: 'assets/images/crypto/${chainName.toLowerCase()}.webp',
iconPath:
'assets/new-ui/crypto_full_icons/${chainName.toLowerCase()}.svg',
keyValue: chain.chainId.toString(),
label: chainName,
onTap: () {
@@ -55,6 +55,7 @@ class _BridgeReceiveAddressInputPageState extends State<BridgeReceiveAddressInpu
ModalTopBar(
title: 'Input Receive Address',
leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context).pop(),
),
SizedBox(height: 48),
@@ -1,4 +1,5 @@
import 'package:hash_wallet/view_model/bridge/bridge_receiving_wallet_option.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/pages/bridge/bridge_confirm_sheet.dart';
import 'package:hash_wallet/new-ui/pages/bridge/bridge_receive_address_input_page.dart';
import 'package:hash_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
@@ -42,6 +43,7 @@ class _BridgeReceivingWalletPageState extends State<BridgeReceivingWalletPage> {
ModalTopBar(
title: 'Receiving Wallet',
leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context).pop(),
),
const SizedBox(height: 48),
@@ -63,7 +65,7 @@ class _BridgeReceivingWalletPageState extends State<BridgeReceivingWalletPage> {
child: HashImageWidget(
borderRadius: 8,
imageUrl: chainName.isNotEmpty
? 'assets/images/crypto/${chainName.toLowerCase()}.webp'
? 'assets/new-ui/crypto_full_icons/${chainName.toLowerCase()}.svg'
: null,
width: 72,
height: 72,
@@ -0,0 +1,544 @@
import "dart:async";
import "package:hash_wallet/buy/payment_method.dart";
import "package:hash_wallet/buy/sell_buy_states.dart";
import "package:hash_wallet/entities/fiat_currency.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/pages/buy_sell/buy_sell_provider_page.dart";
import "package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart";
import "package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_sheet.dart";
import "package:hash_wallet/new-ui/widgets/currency_picker/fiat_currency_picker_sheet.dart";
import "package:hash_wallet/new-ui/widgets/floating_amount_input.dart";
import "package:hash_wallet/new-ui/widgets/money/currency_symbol_text.dart";
import "package:hash_wallet/new-ui/widgets/money/money_text.dart";
import "package:hash_wallet/new-ui/widgets/new_primary_button.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/new-ui/widgets/token_chain_display.dart";
import "package:hash_wallet/src/widgets/alert_with_one_action.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/themes/core/theme_extension.dart";
import "package:hash_wallet/utils/show_pop_up.dart";
import "package:hash_wallet/view_model/buy/buy_sell_view_model.dart";
import "package:cw_core/amount/money.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/currency.dart";
import "package:flutter/cupertino.dart";
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});
final BuySellViewModel buySellViewModel;
@override
State<NewBuySellAmountPage> createState() => _NewBuySellAmountPageState();
}
class _NewBuySellAmountPageState extends State<NewBuySellAmountPage> {
bool _customAmountMode = false;
bool _isLoadingPaymentMethods = false;
final customInputController = TextEditingController();
final customInputFocusNode = FocusNode();
@override
void dispose() {
customInputController.dispose();
customInputFocusNode.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
// this is a hack for the "up to" display to show the actual upper limit.
// limits are loaded alongside quotes, and quotes for some providers only load properly with an amount and payment method selected.
// it'll be refactored soon anyway, i guess.
widget.buySellViewModel.changeFiatAmount(amount: widget.buySellViewModel.defaultAmounts[3]);
widget.buySellViewModel.calculateBestRate();
when(
(_) => widget.buySellViewModel.paymentMethodState is PaymentMethodLoaded,
() => widget.buySellViewModel.selectedPaymentMethod = widget.buySellViewModel.paymentMethods
.firstWhere((item) => item.paymentMethodType == PaymentType.all),
);
}
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(18)),
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceDim,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
child: Column(
children: [
Observer(
builder: (_) => ModalTopBar(
title: _pageTitle,
bottomText: !_customAmountMode || widget.buySellViewModel.maxFiatAmount == null
? null
: "${S.of(context).up_to} ~${widget.buySellViewModel.maxFiatAmount} ${widget.buySellViewModel.fiatCurrency.title}",
leadingIcon: const Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: Navigator.of(context, rootNavigator: true).pop,
),
),
Expanded(
child: Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _customAmountMode
? BuySellCustomAmountInput(
fiatCurrency: widget.buySellViewModel.fiatCurrency,
cryptoCurrency: widget.buySellViewModel.cryptoCurrency,
cryptoAmount: widget.buySellViewModel.cryptoAmount,
isLoading: _isLoadingPaymentMethods,
hasCurrencySelector: widget.buySellViewModel.hasMultipleCurrencies,
onCurrencySelectorPressed: () => selectCryptoCurrency(context),
controller: customInputController,
focusNode: customInputFocusNode,
onContinuePressed: () {
navigateToProviders(context);
},
onChanged: (amount) {
if (widget.buySellViewModel.mode == BuySellPageMode.sell) {
widget.buySellViewModel.setCryptoAmountFromFiat(amount);
} else {
widget.buySellViewModel.changeFiatAmount(amount: amount);
}
},
)
: BuySellDefaultAmountSelector(
key: const ValueKey(0),
defaultAmounts: widget.buySellViewModel.defaultAmounts,
fiatCurrency: widget.buySellViewModel.fiatCurrency,
currentAmount: widget.buySellViewModel.fiatAmount,
hasCurrencySelector: widget.buySellViewModel.hasMultipleCurrencies,
onCurrencySelectorPressed: () => selectCryptoCurrency(context),
onFiatSelectorPressed: () => selectFiatCurrency(context),
cryptoCurrency: widget.buySellViewModel.cryptoCurrency,
isLoading: _isLoadingPaymentMethods,
mode: widget.buySellViewModel.mode,
onSelected: (amount) async {
if (amount == null) {
// this resets the rate and prevents showing 0 usd = 0.something btc
await widget.buySellViewModel.changeFiatAmount(amount: "");
setState(() {
_customAmountMode = true;
});
customInputFocusNode.requestFocus();
} else {
if (widget.buySellViewModel.mode == BuySellPageMode.sell) {
await widget.buySellViewModel.setCryptoAmountFromFiat(amount);
} else {
await widget.buySellViewModel.changeFiatAmount(amount: amount);
}
if (context.mounted) {
await navigateToProviders(context);
}
}
},
),
),
),
),
],
),
),
);
void selectCryptoCurrency(BuildContext context) => CurrencyPickerSheet.show(
context: context,
args: CurrencyPickerArgs(
items: widget.buySellViewModel.activeWalletCurrencies.toList(),
onSelected: (item) => widget.buySellViewModel.changeCryptoCurrency(currency: item),
symbolResolver: widget.buySellViewModel.amountParsingProxy.getCryptoSymbol,
),
);
void selectFiatCurrency(BuildContext context) => FiatCurrencyPickerSheet.show(
context: context,
selected: widget.buySellViewModel.fiatCurrency,
onSelected: (item) => widget.buySellViewModel.changeFiatCurrency(currency: item),
);
String get _pageTitle => widget.buySellViewModel.mode == BuySellPageMode.buy
? S.current.buy
: S.current.sell +
((widget.buySellViewModel.cryptoCurrencies.length == 1)
? " ${widget.buySellViewModel.cryptoCurrencies.first.fullName}"
: "");
Future<void> navigateToProviders(BuildContext context) async {
if (_isLoadingPaymentMethods) {
return;
}
try {
setState(() {
_isLoadingPaymentMethods = true;
});
await asyncWhen(
(_) => [PaymentMethodLoaded, PaymentMethodFailed]
.contains(widget.buySellViewModel.paymentMethodState.runtimeType),
);
if (widget.buySellViewModel.paymentMethodState is PaymentMethodFailed) {
if (context.mounted) {
await showPopUp(
context: context,
builder: (context) => AlertWithOneAction(
alertTitle: S.of(context).failed_to_load_payment_methods,
alertContent: S.of(context).please_try_again_later,
buttonText: "OK",
buttonAction: Navigator.of(context).pop,
),
);
}
return;
}
widget.buySellViewModel.selectedPaymentMethod = widget.buySellViewModel.paymentMethods
.firstWhere((item) => item.paymentMethodType == PaymentType.all);
// unawaited because BuySellProviderPage has a nice little "loading rates..." ui thingie
unawaited(widget.buySellViewModel.calculateBestRate());
final page = BuySellProviderPage(buySellViewModel: widget.buySellViewModel);
if (context.mounted) {
unawaited(
Navigator.of(context).push(
CupertinoPageRoute(
builder: (context) => Material(color: Colors.transparent, child: page),
),
),
);
}
} finally {
setState(() {
_isLoadingPaymentMethods = false;
});
}
}
}
class BuySellCustomAmountInput extends StatelessWidget {
const BuySellCustomAmountInput({
required this.fiatCurrency,
required this.cryptoCurrency,
required this.cryptoAmount,
required this.controller,
required this.onContinuePressed,
required this.isLoading,
required this.onChanged,
required this.focusNode,
required this.hasCurrencySelector,
required this.onCurrencySelectorPressed,
super.key,
});
final FiatCurrency fiatCurrency;
final CryptoCurrency cryptoCurrency;
final String cryptoAmount;
final TextEditingController controller;
final FocusNode focusNode;
final bool isLoading;
final bool hasCurrencySelector;
final VoidCallback onCurrencySelectorPressed;
final VoidCallback onContinuePressed;
final Function(String) onChanged;
@override
Widget build(BuildContext context) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SizedBox.shrink(),
Column(
spacing: 8,
children: [
FloatingAmountInput(
currency: fiatCurrency,
focusNode: focusNode,
controller: controller,
onChanged: onChanged,
),
Opacity(
opacity: cryptoAmount.isEmpty ? 0 : 1,
child: Text(
"≈ ${cryptoAmount} ${cryptoCurrency.symbol}",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
Padding(
padding: const EdgeInsets.all(18),
child: NewPrimaryButton(
disabled: controller.text.isEmpty,
onPressed: onContinuePressed,
isLoading: isLoading,
text: S.of(context).continue_text,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
],
),
);
}
class BuySellDefaultAmountSelector extends StatelessWidget {
const BuySellDefaultAmountSelector({
required this.onFiatSelectorPressed,
required this.defaultAmounts,
required this.fiatCurrency,
required this.mode,
required this.onSelected,
required this.isLoading,
required this.hasCurrencySelector,
required this.onCurrencySelectorPressed,
required this.cryptoCurrency,
super.key,
this.currentAmount,
});
final List<String> defaultAmounts;
final String? currentAmount;
final bool isLoading;
final bool hasCurrencySelector;
final VoidCallback onCurrencySelectorPressed;
final VoidCallback onFiatSelectorPressed;
final CryptoCurrency cryptoCurrency;
final FiatCurrency fiatCurrency;
final BuySellPageMode mode;
final Function(String?) onSelected;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Column(
spacing: 24,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasCurrencySelector)
BuySellCurrencyPickerPill(
title: mode == BuySellPageMode.sell ? S.of(context).sell : S.of(context).buy,
curr: cryptoCurrency,
onTap: onCurrencySelectorPressed,
),
BuySellCurrencyPickerPill(
title: mode == BuySellPageMode.sell
? S.of(context).receive
: S.of(context).purchase_with,
curr: fiatCurrency,
onTap: onFiatSelectorPressed,
),
Text(
mode == BuySellPageMode.sell
? S.of(context).choose_amount_to_sell
: S.of(context).choose_amount_to_buy,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
// +1 for "custom" option
itemCount: defaultAmounts.length + 1,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 16,
mainAxisExtent: 105,
),
itemBuilder: (context, index) {
final String? item = index == defaultAmounts.length ? null : defaultAmounts[index];
return BuySellAmountPill(
isLoading: isLoading && item == currentAmount,
amount: item == null ? null : Money.parse(item, fiatCurrency),
onTap: () => onSelected(item),
);
},
),
],
),
);
}
class BuySellAmountPill extends StatelessWidget {
const BuySellAmountPill({required this.onTap, required this.isLoading, super.key, this.amount});
final Money? amount;
final VoidCallback onTap;
final bool isLoading;
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(9999999999),
border: Border.all(
width: 1,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
gradient: LinearGradient(
colors: [
context.customColors.cardGradientColorPrimary,
context.customColors.cardGradientColorSecondary,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(9999999999),
child: InkWell(
borderRadius: BorderRadius.circular(9999999999),
onTap: onTap,
child: isLoading
? const CupertinoActivityIndicator()
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
spacing: 4,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (amount != null)
MoneyText(
amount!,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
showSymbol: false,
),
Text(
amount?.currency.symbol ?? S.of(context).custom,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: amount == null
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
if (amount == null)
Text(
S.of(context).enter_amount,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
class BuySellCurrencyPickerPill extends StatelessWidget {
const BuySellCurrencyPickerPill(
{required this.title, required this.curr, required this.onTap, super.key});
final String title;
final Currency curr;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
Text(
title,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
GestureDetector(
onTap: onTap,
child: Container(
width: MediaQuery.sizeOf(context).width,
height: 62,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
spacing: 12,
children: [
if (curr is CryptoCurrency)
TokenChainDisplay(size: 30, asset: curr as CryptoCurrency)
else if (curr is FiatCurrency)
SizedBox(
width: 30,
child: Text(
(curr as FiatCurrency).emoji,
style: const TextStyle(fontSize: 24),
)),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
spacing: 2,
children: [
Text(
curr.fullName ?? curr.symbol,
),
CurrencySymbolText(
curr,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
],
),
RotatedBox(
quarterTurns: 2,
child: HashImageWidget(
imageUrl: "assets/new-ui/dropdown_arrow.svg",
width: 8,
height: 8,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
),
],
),
),
),
),
],
);
}
@@ -0,0 +1,161 @@
import "package:hash_wallet/buy/payment_method.dart";
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/pages/buy_sell/buy_sell_redirecting_page.dart";
import "package:hash_wallet/new-ui/widgets/new_primary_button.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/src/widgets/new_list_row/new_list_section.dart";
import "package:hash_wallet/view_model/buy/buy_sell_view_model.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:flutter_mobx/flutter_mobx.dart";
class BuySellConfirmationPage extends StatelessWidget {
const BuySellConfirmationPage({required this.buySellViewModel, super.key});
final BuySellViewModel buySellViewModel;
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(18)),
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceDim,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
child: Column(
children: [
ModalTopBar(
title: _pageTitle,
leadingIcon: const Icon(Icons.arrow_back_ios_new),
onLeadingPressed: Navigator.of(context).pop,
leadingSemanticLabel: S.of(context).seed_alert_back,
),
Expanded(
child: Observer(
builder: (_) => Column(
spacing: 24,
children: [
Column(
spacing: 4,
children: [
Text(
"${buySellViewModel.fiatAmount} ${buySellViewModel.fiatCurrency}",
style: const TextStyle(fontSize: 32),
),
if (buySellViewModel.amountForQuote(buySellViewModel.selectedQuote!) !=
null)
Text(
"≈ ${buySellViewModel.amountForQuote(buySellViewModel.selectedQuote!)?.toStringWithSymbol(fractionalDigits: 8)}",
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
],
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: NewListSections(
sections: {
"": [
ListItemRegularRow(
keyValue: "provider",
label: S.of(context).provider,
trailingWidget: Row(
spacing: 8,
children: [
HashImageWidget(
imageUrl: Theme.of(context).brightness == Brightness.light
? buySellViewModel.selectedQuote!.lightIconPath
: buySellViewModel.selectedQuote!.darkIconPath,
width: 24,
height: 24,
),
Text(
buySellViewModel.selectedQuote!.rampName ??
buySellViewModel.selectedQuote!.provider.title,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
const SizedBox.shrink(),
],
),
),
if (buySellViewModel.selectedQuote!.paymentType.title?.isNotEmpty
?? buySellViewModel.selectedQuote!.customPaymentMethodType?.isNotEmpty
?? false)
ListItemRegularRow(
showArrow: false,
keyValue: "payment method",
label: S.of(context).payment_method,
trailingText: buySellViewModel.selectedQuote!.paymentType.title
?? buySellViewModel.selectedQuote!.customPaymentMethodType,
),
ListItemRegularRow(
showArrow: false,
keyValue: "rate",
label: S.of(context).rate,
trailingText: buySellViewModel.selectedQuote!.topLeftSubTitle,
),
if (_limitsText != null)
ListItemRegularRow(
showArrow: false,
keyValue: "limits",
label: "Limits",
trailingText: _limitsText,
),
],
},
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(18),
child: NewPrimaryButton(
onPressed: () => confirm(context),
text: S.of(context).proceed,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
],
),
),
);
String? get _limitsText {
final limits = buySellViewModel.selectedQuote?.limits;
final max = limits?.max;
if (limits == null || max == null || max == double.infinity) return null;
return "${limits.min ?? 0} - $max";
}
String get _pageTitle =>
"${buySellViewModel.mode == BuySellPageMode.buy ? S.current.buy : S.current.sell} ${buySellViewModel.cryptoCurrency.fullName ?? ""}";
void confirm(BuildContext context) {
final page = BuySellRedirectingPage(buySellViewModel: buySellViewModel);
Navigator.of(context, rootNavigator: true).pop();
Navigator.of(context, rootNavigator: true).push(
CupertinoPageRoute(
builder: (context) => Material(
child: page,
),
),
);
}
}
@@ -0,0 +1,81 @@
import "dart:async";
import "package:hash_wallet/buy/payment_method.dart";
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/src/widgets/new_list_row/new_list_section.dart";
import "package:hash_wallet/view_model/buy/buy_sell_view_model.dart";
import "package:flutter/material.dart";
import "package:flutter_mobx/flutter_mobx.dart";
class BuySellPaymentMethodPage extends StatelessWidget {
const BuySellPaymentMethodPage({required this.buySellViewModel, super.key});
final BuySellViewModel buySellViewModel;
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(18)),
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceDim,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
child: Column(
children: [
ModalTopBar(
title: S.of(context).payment_method,
leadingIcon: const Icon(Icons.arrow_back_ios_new),
onLeadingPressed: Navigator.of(context).pop,
leadingSemanticLabel: S.of(context).seed_alert_back,
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Observer(
builder: (_) => NewListSections(
sections: {
"": buySellViewModel.paymentMethods
.map(
(item) => ListItemRegularRow(
keyValue: item.title,
label: item.title,
showArrow: false,
iconPath: Theme.of(context).brightness == Brightness.light
? item.lightIconPath
: item.darkIconPath,
iconColor: item.paymentMethodType.isMonochromeIcon
? Theme.of(context).colorScheme.onSurfaceVariant
: null,
trailingWidget: buySellViewModel.selectedPaymentMethod == item
? Icon(
Icons.check,
color: Theme.of(context).colorScheme.primary,
size: 16,
)
: null,
onTap: () {
buySellViewModel.changeOption(item);
unawaited(buySellViewModel.calculateBestRate());
Navigator.of(context).pop();
},
),
)
.toList(),
},
),
),
),
),
],
),
),
);
}
@@ -0,0 +1,197 @@
import "package:hash_wallet/buy/buy_quote.dart";
import "package:hash_wallet/buy/sell_buy_states.dart";
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item.dart";
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item_dropdown.dart";
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/pages/buy_sell/buy_sell_confirmation_page.dart";
import "package:hash_wallet/new-ui/pages/buy_sell/buy_sell_payment_method_page.dart";
import "package:hash_wallet/new-ui/widgets/money/money_text.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/src/widgets/new_list_row/new_list_section.dart";
import "package:hash_wallet/view_model/buy/buy_sell_view_model.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:flutter_mobx/flutter_mobx.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
class BuySellProviderPage extends StatefulWidget {
const BuySellProviderPage({required this.buySellViewModel, super.key});
final BuySellViewModel buySellViewModel;
@override
State<BuySellProviderPage> createState() => _BuySellProviderPageState();
}
class _BuySellProviderPageState extends State<BuySellProviderPage> {
bool _allProvidersExpanded = false;
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(18)),
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceDim,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
child: Column(
children: [
ModalTopBar(
title: _pageTitle,
leadingIcon: const Icon(Icons.arrow_back_ios_new),
onLeadingPressed: Navigator.of(context).pop,
leadingSemanticLabel: S.of(context).seed_alert_back,
),
Expanded(
child: Observer(
builder: (_) {
if (widget.buySellViewModel.buySellQuotState is BuySellQuotFailed) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 24,
children: [
const Icon(Icons.warning_amber_outlined, size: 48),
Column(
spacing: 10,
children: [
Text(
S.of(context).could_not_load_quotes,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
),
Text(
(widget.buySellViewModel.buySellQuotState as BuySellQuotFailed)
.errorMessage ??
S.of(context).please_try_again_later,
),
],
),
],
);
}
if (widget.buySellViewModel.buySellQuotState is BuySellQuotLoading) {
return Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8,
children: [
const CupertinoActivityIndicator(),
Text(
S.of(context).loading_rates,
style:
TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
),
);
}
return SingleChildScrollView(
controller: ModalScrollController.of(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: NewListSections(
showHeader: true,
sections: {
"": [
ListItemRegularRow(
keyValue: "payment method",
label: S.of(context).payment_method,
showArrow: true,
onTap: () {
final page = BuySellPaymentMethodPage(
buySellViewModel: widget.buySellViewModel,
);
Navigator.of(context).push(
CupertinoPageRoute(
builder: (context) => Material(
color: Colors.transparent,
child: page,
),
),
);
},
trailingText: widget.buySellViewModel.selectedPaymentMethod?.title,
),
],
S.of(context).available_providers: [
...widget.buySellViewModel.sortedRecommendedQuotes.map(quoteListItem),
if (widget.buySellViewModel.sortedQuotes.isNotEmpty)
ListItemDropdown(
keyValue: "more options",
label: S.of(context).more_options,
onTap: () {
setState(() {
_allProvidersExpanded = !_allProvidersExpanded;
});
},
),
if (_allProvidersExpanded)
...widget.buySellViewModel.sortedQuotes.map(quoteListItem),
],
},
),
),
);
},
),
),
],
),
),
);
String get _pageTitle =>
"${widget.buySellViewModel.mode == BuySellPageMode.buy ? S.current.buy : S.current.sell} ${widget.buySellViewModel.cryptoCurrency.fullName ?? ""}";
ListItem quoteListItem(Quote quote) {
final fiatAmount = widget.buySellViewModel.fiatAmountForQuote(quote);
return ListItemRegularRow(
keyValue: quote.provider.title,
label: quote.rampName ?? quote.provider.title,
secondaryLabel: quote.rampName != null ? quote.provider.title : null,
subtitle: quote.badges.isEmpty ? null : quote.badges.join(" - "),
subtitleColor: Theme.of(context).colorScheme.primary,
iconPath: Theme.of(context).brightness == Brightness.light
? quote.lightIconPath
: quote.darkIconPath,
onTap: () {
widget.buySellViewModel.changeOption(quote);
navigateToConfirmation(context);
},
leadingIconSize: 32,
trailingWidget: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
spacing: 4,
children: [
MoneyText.optional(widget.buySellViewModel.amountForQuote(quote)),
if (fiatAmount != null)
Text(
"= ${fiatAmount.toStringWithSymbol(fractionalDigits: 2, trimZeros: false)}",
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
),
);
}
void navigateToConfirmation(BuildContext context) {
final page = BuySellConfirmationPage(buySellViewModel: widget.buySellViewModel);
Navigator.of(context).push(
CupertinoPageRoute(
builder: (context) => Material(
color: Colors.transparent,
child: page,
),
),
);
}
}
@@ -0,0 +1,129 @@
import "package:hash_wallet/buy/sell_buy_states.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/new_primary_button.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/view_model/buy/buy_sell_view_model.dart";
import "package:flutter/material.dart";
import "package:flutter_mobx/flutter_mobx.dart";
class BuySellRedirectingPage extends StatefulWidget {
const BuySellRedirectingPage({required this.buySellViewModel, super.key});
final BuySellViewModel buySellViewModel;
@override
State<BuySellRedirectingPage> createState() => _BuySellRedirectingPageState();
}
class _BuySellRedirectingPageState extends State<BuySellRedirectingPage> {
bool _hasRedirected = false;
@override
void initState() {
super.initState();
Future.delayed(const Duration(seconds: 2)).then((_) {
WidgetsBinding.instance
.addPostFrameCallback((_) => widget.buySellViewModel.launchTrade(context));
setState(() {
_hasRedirected = true;
});
});
}
@override
Widget build(BuildContext context) => PopScope(
canPop: false,
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(18)),
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceDim,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Observer(
builder: (_) {
final showExitButton =
_hasRedirected || widget.buySellViewModel.buySellQuotState is BuySellQuotFailed;
return SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SizedBox.shrink(),
Observer(
builder: (_) {
if (widget.buySellViewModel.buySellQuotState is BuySellQuotFailed) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 24,
children: [
const Icon(Icons.warning_amber_outlined, size: 48),
Column(
spacing: 10,
children: [
Text(
S.of(context).could_not_proceed_with_trade,
style:
const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
),
Text(
(widget.buySellViewModel.buySellQuotState as BuySellQuotFailed)
.errorMessage ??
S.of(context).please_try_again_later,
),
],
),
],
);
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 24,
children: [
HashImageWidget(
imageUrl: Theme.of(context).brightness == Brightness.light
? widget.buySellViewModel.selectedQuote!.lightIconPath
: widget.buySellViewModel.selectedQuote!.darkIconPath,
width: 64,
height: 64,
),
Column(
spacing: 10,
children: [
Text(
"${S.of(context).connecting_you_to} ${widget.buySellViewModel.selectedQuote!.provider.title}...",
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
),
Text(
"${widget.buySellViewModel.fiatAmount} ${widget.buySellViewModel.fiatCurrency} → ${widget.buySellViewModel.amountForQuote(widget.buySellViewModel.selectedQuote!)?.toStringWithSymbol(fractionalDigits: 8)}",
),
],
),
],
);
},
),
showExitButton
? Padding(
padding: const EdgeInsets.all(18),
child: NewPrimaryButton(
onPressed: Navigator.of(context).pop,
text: S.of(context).close,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
)
: const SizedBox.shrink(),
],
),
);
},
),
),
);
}
+475 -195
View File
@@ -1,18 +1,97 @@
import 'dart:async';
import 'dart:math';
import "dart:math";
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/cards/balance_card.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import "package:hash_wallet/di.dart";
import "package:hash_wallet/new-ui/modal_navigator.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart";
import "package:hash_wallet/new-ui/widgets/account_confirmation_content.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/cards/balance_card.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/src/widgets/alert_with_two_actions.dart";
import "package:hash_wallet/src/widgets/base_alert_dialog.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/src/widgets/new_list_row/list_Item_style_wrapper.dart";
import "package:hash_wallet/utils/show_pop_up.dart";
import "package:hash_wallet/view_model/dashboard/dashboard_view_model.dart";
import "package:hash_wallet/view_model/wallet_account_list/account_list_item.dart";
import "package:cw_core/card_design.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:flutter_bloc/flutter_bloc.dart";
class CardCustomizer extends StatefulWidget {
const CardCustomizer({super.key, required this.cryptoTitle, required this.cryptoName});
const CardCustomizer({
required this.cryptoTitle,
required this.cryptoName,
this.account,
this.fiatBalance,
super.key,
});
final String cryptoTitle;
final String cryptoName;
final AccountListItem? account;
final String? fiatBalance;
static Future<bool> show({
required BuildContext context,
required DashboardViewModel dashboardViewModel,
required AccountListItem? account,
bool lightningMode = false,
bool canHide = false,
bool asPage = false,
}) async {
final bloc = getIt.get<CardCustomizerBloc>(
param1: CardCustomizerBlocParams(
lightningMode: lightningMode,
amountDisplayMode: dashboardViewModel.settingsStore.displayAmountsInSatoshi,
canHide: canHide,
),
);
if (bloc.state is CardCustomizerNotLoaded) {
await bloc.stream.firstWhere((state) => state is! CardCustomizerNotLoaded);
}
if (!context.mounted) {
await bloc.close();
return false;
}
final balanceViewModel = dashboardViewModel.balanceViewModel;
final customizer = BlocProvider.value(
value: bloc,
child: Material(
child: CardCustomizer(
cryptoTitle: dashboardViewModel.wallet.currency.fullName ??
dashboardViewModel.wallet.currency.name,
cryptoName: dashboardViewModel.wallet.currency.name,
account: account,
fiatBalance: account == null ? null : balanceViewModel.accountFiatBalance(account),
),
),
);
final archived = await (asPage
? Navigator.of(context).push<bool>(CupertinoPageRoute(builder: (_) => customizer))
: CupertinoScaffold.showCupertinoModalBottomSheet<bool>(
barrierColor: Colors.black.withAlpha(60),
context: context,
builder: (context) => ModalNavigator(
parentContext: context,
heightMode: ModalHeightModes.fullScreen,
rootPage: customizer,
),
));
// Save edits before AccountHidden writes the hidden state.
bloc.add(DesignSaved());
await bloc.stream.firstWhere((state) => state is CardCustomizerSaved);
if (archived == true) {
bloc.add(AccountHidden());
await bloc.stream.firstWhere((state) => state is CardCustomizerSaved);
}
await bloc.close();
return archived == true;
}
@override
State<CardCustomizer> createState() => _CardCustomizerState();
@@ -20,9 +99,10 @@ class CardCustomizer extends StatefulWidget {
class _CardCustomizerState extends State<CardCustomizer> {
final accountNameController = TextEditingController();
bool editEnabled = false;
late final CardCustomizerBloc bloc;
bool get _isAccount => widget.account != null;
@override
void initState() {
super.initState();
@@ -30,78 +110,132 @@ class _CardCustomizerState extends State<CardCustomizer> {
bloc = context.read<CardCustomizerBloc>();
if (bloc.state is! CardCustomizerNotLoaded) {
editEnabled = bloc.state.accountName.isNotEmpty;
accountNameController.text = bloc.state.accountName;
} else {
late final StreamSubscription sub;
sub = bloc.stream.listen((state) {
if (state is! CardCustomizerNotLoaded) {
setState(() {
editEnabled = state.accountName.isNotEmpty;
});
sub.cancel();
}
});
}
}
bool _showIconStylePanel(CardCustomizerState state) {
final design = state.availableDesigns[state.selectedDesignIndex];
return design.backgroundType == CardDesignBackgroundTypes.svgIcon &&
state.availableIconPaths.isNotEmpty;
}
CardDesign _cardStylePreviewDesign(CardCustomizerState state, int index) {
var design = state.availableDesigns[index].withGradient(state.selectedColor);
if (design.backgroundType == CardDesignBackgroundTypes.svgIcon &&
state.availableIconPaths.isNotEmpty) {
design = design.withIcon(state.availableIconPaths[state.selectedIconIndex]);
}
return design;
}
Future<void> _requestArchive() async {
final account = AccountListItem(
id: widget.account!.id,
label: bloc.state.accountName,
balance: widget.account!.balance,
);
final isFunded = account.isFunded;
final confirmed = await showPopUp<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => AlertWithTwoActions(
alertTitle: isFunded
? S.of(dialogContext).archive_account_confirmation_title
: S.of(dialogContext).archive_account_title,
alertContent: "",
alertContentTextWidget: ArchiveConfirmationContent(
account: account,
fiatBalance: widget.fiatBalance,
isFunded: isFunded,
),
leftButtonText: S.of(dialogContext).cancel,
rightButtonText: S.of(dialogContext).continue_text,
leftAlertButtonStyle: AlertButtonStyle.primary(dialogContext),
rightAlertButtonStyle: AlertButtonStyle.secondary(dialogContext),
actionLeftButton: () => Navigator.of(dialogContext).pop(false),
actionRightButton: () => Navigator.of(dialogContext).pop(true),
),
);
if (confirmed != true || !mounted) {
return;
}
Navigator.of(context).pop(true);
}
@override
Widget build(BuildContext context) {
return BlocListener<CardCustomizerBloc, CardCustomizerState>(
listenWhen: (previous, current) => previous.accountName != current.accountName,
listener: (context, state) {
if (accountNameController.text != state.accountName) {
accountNameController.text = state.accountName;
}
},
child: BlocBuilder<CardCustomizerBloc, CardCustomizerState>(
builder: (context, state) {
if (state is CardCustomizerNotLoaded) return SizedBox.shrink();
return PopScope(
child: SingleChildScrollView(
child: SafeArea(
child: Column(
spacing: 25.0,
mainAxisSize: MainAxisSize.min,
children: [
ModalTopBar(
title: editEnabled ? S.of(context).edit_account : S.of(context).edit_card,
leadingIcon: Icon(Icons.close),
// trailingIcon: editEnabled ? Icon(Icons.delete_forever) : null,
onLeadingPressed: () => Navigator.of(context).maybePop(),
// onTrailingPressed: () {},
),
if (editEnabled)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
spacing: 8.0,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context).account_name),
TextField(
maxLength: 32,
decoration: InputDecoration(counterText: ""),
onChanged: (value) {
context.read<CardCustomizerBloc>().add(AccountNameChanged(value));
},
controller: accountNameController,
)
],
),
void dispose() {
accountNameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => BlocListener<CardCustomizerBloc, CardCustomizerState>(
listenWhen: (previous, current) => previous.accountName != current.accountName,
listener: (context, state) {
if (accountNameController.text != state.accountName) {
accountNameController.text = state.accountName;
}
},
child: BlocBuilder<CardCustomizerBloc, CardCustomizerState>(
builder: (context, state) {
if (state is CardCustomizerNotLoaded) {
return const SizedBox.shrink();
}
return PopScope(
child: SingleChildScrollView(
child: SafeArea(
child: Column(
spacing: 25,
mainAxisSize: MainAxisSize.min,
children: [
ModalTopBar(
title: _isAccount ? S.of(context).edit_account : S.of(context).edit_card,
subtitle: _isAccount ? "#${widget.account!.id + 1}" : null,
leadingIcon:
Icon(_isAccount ? Icons.arrow_back_ios_new : Icons.close, size: 18),
leadingSemanticLabel:
_isAccount ? S.of(context).seed_alert_back : S.of(context).close,
onLeadingPressed: () => Navigator.of(context).maybePop(),
),
BalanceCard(
width: min(MediaQuery.of(context).size.width * 0.87, 768),
selected: true,
designSwitchDuration: Duration(milliseconds: 300),
accountName: editEnabled ? state.accountName : "",
balance: "0.00",
assetName: state.displaySats ? "sats" : widget.cryptoName,
capitalizeAssetName: !state.displaySats,
design: state.selectedDesign,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
BalanceCard(
width: min(MediaQuery.of(context).size.width * 0.87, 768),
selected: true,
designSwitchDuration: const Duration(milliseconds: 300),
accountIndex: _isAccount ? widget.account!.id + 1 : null,
accountName: !_isAccount
? ""
: state.accountName.trim().isEmpty
? S.of(context).unnamed_account
: state.accountName,
accountBalance: widget.account?.balance,
balance: _isAccount ? "" : "0.00",
fiatBalance: widget.fiatBalance ?? "",
assetName: state.displaySats ? "sats" : widget.cryptoName,
capitalizeAssetName: !state.displaySats,
design: state.selectedDesign,
),
if (_isAccount)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context).account_name),
TextField(
maxLength: 32,
decoration: const InputDecoration(counterText: ""),
onChanged: (value) {
context.read<CardCustomizerBloc>().add(AccountNameChanged(value));
},
controller: accountNameController,
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
@@ -111,146 +245,292 @@ class _CardCustomizerState extends State<CardCustomizer> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
spacing: 8.0,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context).card_style),
Container(
height: 63,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: state.availableDesigns.length,
separatorBuilder: (context, index) {
return SizedBox(width: 8.0);
},
itemBuilder: (context, index) {
return GestureDetector(
onTap: () {
context
.read<CardCustomizerBloc>()
.add(CardDesignSelected(index));
},
child: AnimatedContainer(
duration: Duration(milliseconds: 300),
decoration: ShapeDecoration(
shape: RoundedSuperellipseBorder(
side: BorderSide(
color:
((index == state.selectedDesignIndex)
padding: const EdgeInsets.all(12),
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context).card_style),
SizedBox(
height: 63,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: state.availableDesigns.length,
separatorBuilder: (context, index) =>
const SizedBox(width: 8),
itemBuilder: (context, index) => GestureDetector(
onTap: () {
context
.read<CardCustomizerBloc>()
.add(CardDesignSelected(index));
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: ShapeDecoration(
shape: RoundedSuperellipseBorder(
side: BorderSide(
color: ((index == state.selectedDesignIndex)
? Theme.of(context).colorScheme.onSurface
: Colors.transparent),
width: 1,
),
borderRadius: BorderRadiusGeometry.circular(12),
),
),
child: AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: index == state.selectedDesignIndex ? 0.94 : 1,
child: BalanceCard(
width: 96,
borderRadius: 10,
selected: false,
designSwitchDuration:
const Duration(milliseconds: 300),
design: _cardStylePreviewDesign(state, index),
),
),
),
),
),
),
],
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
),
),
child: _showIconStylePanel(state)
? Column(
key: const ValueKey("icon_style_panel"),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context).icon_style),
SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: state.availableIconPaths.length,
separatorBuilder: (context, _) =>
const SizedBox(width: 8),
itemBuilder: (context, index) {
final icon = state.availableIconPaths[index];
final isSelected =
index == state.selectedIconIndex;
return GestureDetector(
onTap: () {
context
.read<CardCustomizerBloc>()
.add(IconStyleSelected(index));
},
child: AnimatedContainer(
duration:
const Duration(milliseconds: 200),
width: 48,
height: 48,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color: Theme.of(context).brightness ==
Brightness.light
? Theme.of(context)
.colorScheme
.onSurfaceVariant
.withAlpha(64)
: Theme.of(context)
.colorScheme
.surfaceContainerHigh,
border: Border.all(
color: isSelected
? Theme.of(context)
.colorScheme
.onSurface
: Colors.transparent),
width: 1),
borderRadius:
BorderRadiusGeometry.circular(12),
),
),
child: AnimatedScale(
duration: Duration(milliseconds: 200),
scale: index == state.selectedDesignIndex
? 0.94
: 1,
child: BalanceCard(
width: 96,
borderRadius: 10,
selected: false,
designSwitchDuration:
Duration(milliseconds: 300),
design: state.availableDesigns[index],
gradient: state.selectedDesign.gradient,
),
: Colors.transparent,
width: 2,
),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: HashImageWidget(
imageUrl: icon.path,
),
),
),
);
},
),
),
);
}),
),
],
)),
SizedBox(height: 8),
],
),
),
],
)
: const SizedBox.shrink(key: ValueKey("icon_style_hidden")),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16)),
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
padding: const EdgeInsets.all(12),
child: Column(
spacing: 8.0,
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context).color),
Container(
width: double.infinity,
child: Wrap(
direction: Axis.horizontal,
spacing: 4, // space between items in a row
runSpacing: 8,
children: List.generate(state.availableColors.length,
(index) {
return Material(
SizedBox(
width: double.infinity,
child: Wrap(
direction: Axis.horizontal,
spacing: 4,
runSpacing: 8,
children: List.generate(
state.availableColors.length,
(index) => Material(
borderRadius: BorderRadius.circular(999999999),
child: InkWell(
borderRadius: BorderRadius.circular(999999999),
child: InkWell(
borderRadius: BorderRadius.circular(999999999),
onTap: () {
context
.read<CardCustomizerBloc>()
.add(ColorSelected(index));
},
child: Stack(
children: [
AnimatedOpacity(
duration: Duration(milliseconds: 200),
opacity: index == state.selectedColorIndex
? 1
: 0,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(99999999),
border: Border.all(
color: Theme.of(context)
.colorScheme
.onSurface))),
),
AnimatedScale(
duration: Duration(milliseconds: 200),
scale: index == state.selectedColorIndex
? 0.8
: 1,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(99999999),
gradient:
state.availableColors[index]),
onTap: () {
context
.read<CardCustomizerBloc>()
.add(ColorSelected(index));
},
child: Stack(
children: [
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity:
index == state.selectedColorIndex ? 1 : 0,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(99999999),
border: Border.all(
color: Theme.of(context)
.colorScheme
.onSurface,
),
),
),
],
),
),
AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: index == state.selectedColorIndex
? 0.8
: 1,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(99999999),
gradient: state.availableColors[index],
),
),
),
],
),
);
}),
)),
),
),
),
),
),
],
),
),
)
),
],
),
)),
],
),
),
if (bloc.canHide)
Padding(
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
child: ListItemStyleWrapper(
isFirstInSection: true,
isLastInSection: true,
onTap: _requestArchive,
builder: (context, textStyle, labelStyle) => ConstrainedBox(
constraints: const BoxConstraints(minHeight: 40),
child: Row(
children: [
Padding(
padding: const EdgeInsets.all(2.5),
child: HashImageWidget(
imageUrl: "assets/new-ui/archived.svg",
width: 19,
height: 19,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
S.of(context).archive_account,
style: textStyle.copyWith(
height: 18 / 14,
letterSpacing: -0.07,
),
),
Text(
S.of(context).archive_account_reversible,
style: labelStyle.copyWith(
fontSize: 12,
fontWeight: FontWeight.w400,
height: 15 / 12,
letterSpacing: -0.06,
),
),
],
),
),
const SizedBox(width: 8),
SizedBox.square(
dimension: 16,
child: Align(
alignment: Alignment.centerRight,
child: HashImageWidget(
imageUrl: "assets/new-ui/arrow_right.svg",
width: 7,
height: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
),
),
),
],
),
),
),
),
);
},
),
);
}
);
},
),
);
}
+124
View File
@@ -0,0 +1,124 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/page_open_listener.dart";
import "package:hash_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/asset_grid.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/asset_grid_header.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/chart_header.dart";
import "package:hash_wallet/src/widgets/hash_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) => BlocBuilder<ChartsBloc, ChartsState>(
bloc: chartsBloc,
builder: (context, state) {
if (state is ChartsInitial) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 24,
children: [
HashImageWidget(
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",
),
],
),
);
}
if (state is ChartsDisabled) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 12,
children: [
HashImageWidget(
width: 36,
height: 36,
imageUrl: "assets/new-ui/navbar/charts.svg",
),
Text(
S.of(context).charts_unavailable,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
),
Text(S.of(context).charts_unavailable_desc),
],
),
);
}
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(
bloc: chartsBloc,
currency: state.pinnedCurrency,
chartHeight: 100,
chartPadding: 22,
centered: false,
favorite: true,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Column(
spacing: 24,
children: [ChartsAssetGridHeader(bloc: chartsBloc), ChartsAssetGrid(bloc: chartsBloc)],
),
),
const SizedBox(
height: 96,
),
],
),
),
),
],
),
);
},
);
}
+28 -4
View File
@@ -4,15 +4,25 @@ import 'package:hash_wallet/new-ui/widgets/modal_header.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart';
import 'package:cw_core/unspent_coin_type.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import "package:hash_wallet/view_model/unspent_coins/unspent_coins_item.dart";
class CoinControlPageArgs {
final bool canEdit;
final UnspentCoinType? coinTypeToSpendFrom;
const CoinControlPageArgs({required this.canEdit, this.coinTypeToSpendFrom});
}
class NewCoinControlPage extends StatefulWidget {
const NewCoinControlPage({super.key, required this.unspentCoinsListViewModel});
const NewCoinControlPage(
{super.key, required this.unspentCoinsListViewModel, required this.canEdit});
final UnspentCoinsListViewModel unspentCoinsListViewModel;
final bool canEdit;
@override
State<NewCoinControlPage> createState() => _NewCoinControlPageState();
@@ -94,9 +104,12 @@ class _NewCoinControlPageState extends State<NewCoinControlPage> {
child: ModalHeader(
iconPath: "assets/new-ui/settings_row_icons/coin-control.svg",
title: "Coin Control",
message: S.of(context).coin_control_desc),
message: widget.canEdit
? S.of(context).coin_control_desc
: S.of(context).coin_control_desc_no_edit),
),
if (widget.unspentCoinsListViewModel.items.isNotEmpty)
if (widget.unspentCoinsListViewModel.items.isNotEmpty &&
widget.canEdit)
Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
@@ -124,6 +137,10 @@ class _NewCoinControlPageState extends State<NewCoinControlPage> {
)
],
),
)
else
SizedBox(
height: 24,
),
if (widget.unspentCoinsListViewModel.nonFrozenItems.isEmpty &&
widget.unspentCoinsListViewModel.frozenItems.isEmpty) ...[
@@ -144,6 +161,7 @@ class _NewCoinControlPageState extends State<NewCoinControlPage> {
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: CoinControlListSection(
canEdit: widget.canEdit,
items: widget.unspentCoinsListViewModel.nonFrozenItems,
unspentCoinsListViewModel:
widget.unspentCoinsListViewModel),
@@ -170,6 +188,7 @@ class _NewCoinControlPageState extends State<NewCoinControlPage> {
.onSurfaceVariant),
),
CoinControlListSection(
canEdit: widget.canEdit,
items: widget
.unspentCoinsListViewModel.frozenItems,
unspentCoinsListViewModel:
@@ -194,9 +213,13 @@ class _NewCoinControlPageState extends State<NewCoinControlPage> {
class CoinControlListSection extends StatelessWidget {
const CoinControlListSection(
{super.key, required this.items, required this.unspentCoinsListViewModel});
{super.key,
required this.items,
required this.unspentCoinsListViewModel,
required this.canEdit});
final List<UnspentCoinsItem> items;
final bool canEdit;
final UnspentCoinsListViewModel unspentCoinsListViewModel;
@override
@@ -231,6 +254,7 @@ class CoinControlListSection extends StatelessWidget {
isLoading: item.isBeingSaved,
isFirst: index == 0,
isLast: index == items.length - 1,
hasCheckbox: canEdit,
onCheckBoxTap: item.isFrozen
? null
: () async {
+246
View File
@@ -0,0 +1,246 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/modern_button.dart";
import "package:hash_wallet/new-ui/widgets/new_primary_button.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/store/settings_store.dart";
import "package:hash_wallet/themes/core/theme_extension.dart";
import "package:flutter/material.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
abstract class EducationPage extends StatefulWidget {
const EducationPage({required this.settingsStore, super.key});
final SettingsStore settingsStore;
String get educationId;
String get title;
String get iconPath;
String get completionLabel;
List<EducationSlide> get slides;
String progressLabel(int current, int total);
bool get isDismissed => settingsStore.isEducationDismissed(educationId);
Future<void> show(BuildContext context) async {
await showCupertinoModalBottomSheet<void>(
useRootNavigator: true,
context: context,
backgroundColor: Colors.transparent,
builder: (_) => this,
);
await settingsStore.dismissEducation(educationId);
}
@override
State<EducationPage> createState() => _EducationPageState();
}
class _EducationPageState extends State<EducationPage> {
final PageController _pageController = PageController();
int _currentPage = 0;
bool _isCompleting = false;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _complete() {
if (_isCompleting) {
return;
}
_isCompleting = true;
Navigator.of(context).pop();
}
Future<void> _continue(int pageCount) async {
if (_currentPage == pageCount - 1) {
_complete();
return;
}
await _pageController.nextPage(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
}
@override
Widget build(BuildContext context) {
final pages = widget.slides;
return Material(
color: Colors.transparent,
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
),
child: SafeArea(
top: false,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(18),
child: Row(
children: [
HashImageWidget(
imageUrl: widget.iconPath,
width: 24,
height: 24,
),
const SizedBox(width: 8),
Text(widget.title, style: Theme.of(context).textTheme.headlineMedium),
const Spacer(),
ModernButton(
size: 36,
icon: const Icon(Icons.close),
semanticLabel: S.of(context).close,
onPressed: _complete,
),
],
),
),
Expanded(
child: PageView(
controller: _pageController,
onPageChanged: (page) => setState(() => _currentPage = page),
children: pages,
),
),
Semantics(
label: widget.progressLabel(_currentPage + 1, pages.length),
child: ExcludeSemantics(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
pages.length,
(index) => AnimatedContainer(
key: ValueKey("education-dot-$index"),
duration: const Duration(milliseconds: 200),
width: 8,
height: 8,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: index == _currentPage
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
),
),
),
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: NewPrimaryButton(
onPressed: () => _continue(pages.length),
text: _currentPage == pages.length - 1
? widget.completionLabel
: S.of(context).continue_text,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(height: 12),
],
),
),
),
);
}
}
class EducationSlide extends StatelessWidget {
const EducationSlide({
required this.children,
super.key,
this.distributeChildren = false,
});
final List<Widget> children;
final bool distributeChildren;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 8),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight - 16),
child: distributeChildren
? IntrinsicHeight(
child: Column(
children: [
for (var index = 0; index < children.length; index++) ...[
children[index],
if (index < children.length - 1) ...[
const SizedBox(height: 24),
const Spacer(),
],
],
],
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: children,
),
),
),
);
}
class EducationText extends StatelessWidget {
const EducationText({
required this.text,
super.key,
this.highlightedText,
this.secondary = false,
this.warning = false,
});
final String text;
final String? highlightedText;
final bool secondary;
final bool warning;
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodyLarge?.copyWith(
fontSize: 14,
fontWeight: FontWeight.w400,
color: warning
? context.customColors.warningOutlineColor
: secondary
? Theme.of(context).colorScheme.onSurfaceVariant
: Theme.of(context).colorScheme.onSurface,
);
final highlight = highlightedText;
final highlightStart = highlight == null ? -1 : text.indexOf(highlight);
if (highlight == null || highlightStart < 0) {
return Text(text, textAlign: TextAlign.center, style: style);
}
final highlightEnd = highlightStart + highlight.length;
return Text.rich(
TextSpan(
style: style,
children: [
TextSpan(text: text.substring(0, highlightStart)),
TextSpan(
text: text.substring(highlightStart, highlightEnd),
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
TextSpan(text: text.substring(highlightEnd)),
],
),
textAlign: TextAlign.center,
);
}
}
+480
View File
@@ -0,0 +1,480 @@
import "dart:async";
import "package:hash_wallet/core/utilities.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/account_confirmation_content.dart";
import "package:hash_wallet/new-ui/widgets/modal_page_wrapper.dart";
import "package:hash_wallet/new-ui/widgets/money/money_text.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/src/widgets/alert_with_two_actions.dart";
import "package:hash_wallet/src/widgets/base_alert_dialog.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/utils/show_pop_up.dart";
import "package:hash_wallet/view_model/dashboard/dashboard_view_model.dart";
import "package:hash_wallet/view_model/wallet_account_list/account_list_item.dart";
import "package:hash_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart";
import "package:cw_core/balance_card_layout.dart";
import "package:cw_core/balance_card_style_settings.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:flutter_mobx/flutter_mobx.dart";
class HiddenAccountsPage extends StatefulWidget {
const HiddenAccountsPage({
required this.accountListViewModel,
required this.dashboardViewModel,
super.key,
});
final WalletAccountListViewModel accountListViewModel;
final DashboardViewModel dashboardViewModel;
@override
State<HiddenAccountsPage> createState() => _HiddenAccountsPageState();
}
class _HiddenAccountsPageState extends State<HiddenAccountsPage> {
final List<AccountListItem> _items = [];
final Set<int> _accountsBeingRestored = <int>{};
bool _isLoading = true;
int get _walletInfoId => widget.dashboardViewModel.wallet.walletInfo.internalId;
@override
void initState() {
super.initState();
unawaited(_loadAccounts());
}
Future<BalanceCardLayout> _layout() async => BalanceCardLayout.resolve(
accountIndices: widget.accountListViewModel.accounts.map((account) => account.id).toList(),
settings: await BalanceCardStyleSettings.getAll(_walletInfoId),
);
Future<void> _loadAccounts() async {
final accounts = widget.accountListViewModel.accounts;
final layout = await _layout();
final hiddenAccounts = <AccountListItem>[];
for (final accountIndex in layout.hidden) {
final account = accounts.firstWhereOrNull((account) => account.id == accountIndex);
if (account != null) {
hiddenAccounts.add(account);
}
}
if (!mounted) {
return;
}
setState(() {
_items
..clear()
..addAll(hiddenAccounts);
_isLoading = false;
});
}
Future<bool> _confirmUnarchival(AccountListItem account) async {
final isFunded = account.isFunded;
final result = await showPopUp<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => AlertWithTwoActions(
alertTitle: S.of(dialogContext).unarchive_account_title,
alertContent: "",
alertContentTextWidget: Column(
mainAxisSize: MainAxisSize.min,
children: [
AccountSummary(account: account),
const SizedBox(height: 20),
Text(
isFunded
? S.of(dialogContext).unarchive_account_funds_description
: S.of(dialogContext).unarchive_account_description,
textAlign: TextAlign.center,
style: Theme.of(dialogContext).textTheme.bodyMedium,
),
if (isFunded) ...[
const SizedBox(height: 20),
AccountFundsSummary(
account: account,
fiatBalance: widget.dashboardViewModel.balanceViewModel.accountFiatBalance(account),
borderColor: Theme.of(dialogContext).colorScheme.primary,
),
],
],
),
leftButtonText: S.of(dialogContext).cancel,
rightButtonText: S.of(dialogContext).continue_text,
leftAlertButtonStyle: AlertButtonStyle.secondary(dialogContext),
rightAlertButtonStyle: AlertButtonStyle.primary(dialogContext),
actionLeftButton: () => Navigator.of(dialogContext).pop(false),
actionRightButton: () => Navigator.of(dialogContext).pop(true),
),
);
return result ?? false;
}
Future<void> _unarchive(AccountListItem account) async {
final accountForConfirmation =
widget.accountListViewModel.accounts.firstWhereOrNull((item) => item.id == account.id) ??
account;
final confirmed = await _confirmUnarchival(accountForConfirmation);
if (!confirmed || !mounted) {
return;
}
setState(() => _accountsBeingRestored.add(account.id));
try {
final restored = (await _layout()).unhiding(account.id);
await BalanceCardStyleSettings.setVisibleOrder(_walletInfoId, restored.orders);
final latestAccount =
widget.accountListViewModel.accounts.firstWhereOrNull((item) => item.id == account.id) ??
account;
await widget.accountListViewModel.select(latestAccount);
await widget.dashboardViewModel.loadCardDesigns();
await _loadAccounts();
} finally {
if (mounted) {
setState(() => _accountsBeingRestored.remove(account.id));
}
}
}
@override
Widget build(BuildContext context) => ModalPageWrapper(
topBar: ModalTopBar(
title: S.of(context).archived_accounts,
leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: Navigator.of(context).pop,
),
content: _isLoading
? const SizedBox(
height: 320,
child: Center(child: CupertinoActivityIndicator()),
)
: _items.isEmpty
? const _EmptyArchiveView()
: _PopulatedArchiveView(
accounts: _items,
accountsBeingRestored: _accountsBeingRestored,
dashboardViewModel: widget.dashboardViewModel,
onUnarchive: _unarchive,
),
);
}
class _EmptyArchiveView extends StatelessWidget {
const _EmptyArchiveView();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
height: MediaQuery.sizeOf(context).height - 180,
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ArchiveIcon(size: 50, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 12),
Text(
S.of(context).no_archived_accounts,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
Text(
S.of(context).no_archived_accounts_description,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
}
class _PopulatedArchiveView extends StatelessWidget {
const _PopulatedArchiveView({
required this.accounts,
required this.accountsBeingRestored,
required this.dashboardViewModel,
required this.onUnarchive,
});
final List<AccountListItem> accounts;
final Set<int> accountsBeingRestored;
final DashboardViewModel dashboardViewModel;
final Future<void> Function(AccountListItem account) onUnarchive;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fundedAccounts = accounts.where((account) => account.isFunded).toList(growable: false);
final emptyAccounts =
accounts.where((account) => !account.isFunded).toList(growable: false);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ArchiveIcon(size: 50, color: theme.colorScheme.primary),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 36),
child: Text(
S.of(context).select_account_to_unarchive,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (fundedAccounts.isNotEmpty) ...[
_ArchiveSectionHeader(
title: S.of(context).funded_accounts,
description: S.of(context).funded_accounts_description,
),
const SizedBox(height: 12),
_accountRows(context, fundedAccounts),
],
if (fundedAccounts.isNotEmpty && emptyAccounts.isNotEmpty) const SizedBox(height: 28),
if (emptyAccounts.isNotEmpty) ...[
_ArchiveSectionHeader(title: S.of(context).empty_accounts),
const SizedBox(height: 12),
_accountRows(context, emptyAccounts),
],
],
);
}
Widget _accountRows(BuildContext context, List<AccountListItem> sectionAccounts) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(18),
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (var index = 0; index < sectionAccounts.length; index++) ...[
_ArchivedAccountRow(
account: sectionAccounts[index],
dashboardViewModel: dashboardViewModel,
isBusy: accountsBeingRestored.contains(sectionAccounts[index].id),
onTap: accountsBeingRestored.contains(sectionAccounts[index].id)
? null
: () => onUnarchive(sectionAccounts[index]),
),
if (index < sectionAccounts.length - 1)
Divider(
height: 1,
indent: 48,
color: Theme.of(context).colorScheme.outlineVariant,
),
],
],
),
);
}
class _ArchivedAccountRow extends StatelessWidget {
const _ArchivedAccountRow({
required this.account,
required this.dashboardViewModel,
required this.isBusy,
required this.onTap,
});
final AccountListItem account;
final DashboardViewModel dashboardViewModel;
final bool isBusy;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final label = account.label.trim();
return Material(
color: theme.colorScheme.surfaceContainer,
child: Semantics(
button: true,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
HashImageWidget(
imageUrl: "assets/new-ui/account.svg",
width: 24,
height: 24,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
children: [
TextSpan(
text: "${account.id + 1}. ",
style: TextStyle(color: theme.colorScheme.primary),
),
TextSpan(
text: label.isEmpty ? S.of(context).unnamed_account : label,
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium,
),
],
),
),
const SizedBox(width: 8),
_AccountBalanceTrailing(
account: account,
dashboardViewModel: dashboardViewModel,
isBusy: isBusy,
),
],
),
),
),
),
);
}
}
class _ArchiveSectionHeader extends StatelessWidget {
const _ArchiveSectionHeader({required this.title, this.description});
final String title;
final String? description;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
if (description != null) ...[
const SizedBox(height: 4),
Text(
description!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
);
}
}
class _AccountBalanceTrailing extends StatelessWidget {
const _AccountBalanceTrailing({
required this.account,
required this.dashboardViewModel,
required this.isBusy,
});
final AccountListItem account;
final DashboardViewModel dashboardViewModel;
final bool isBusy;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (isBusy) {
return const CupertinoActivityIndicator(radius: 10);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (account.isFunded) ...[
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 142),
child: Observer(
builder: (_) {
final fiatBalance = dashboardViewModel.balanceViewModel.accountFiatBalance(account);
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
MoneyText(
account.balance,
fractionalDigits: account.balance.decimals,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
if (fiatBalance != null) ...[
const SizedBox(height: 2),
Text(
fiatBalance,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
);
},
),
),
const SizedBox(width: 8),
],
Icon(
Icons.arrow_forward_ios_rounded,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
],
);
}
}
class _ArchiveIcon extends StatelessWidget {
const _ArchiveIcon({required this.size, required this.color});
final double size;
final Color color;
@override
Widget build(BuildContext context) => SizedBox.square(
dimension: size,
child: Center(
child: HashImageWidget(
imageUrl: "assets/new-ui/archived.svg",
width: size * 19 / 24,
height: size * 19 / 24,
color: color,
),
),
);
}
+306 -216
View File
@@ -1,31 +1,44 @@
import 'package:hash_wallet/core/auth_service.dart';
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/modal_navigator.dart';
import 'package:hash_wallet/new-ui/pages/account_customizer.dart';
import 'package:hash_wallet/new-ui/pages/card_customizer.dart';
import 'package:hash_wallet/new-ui/pages/settings_page.dart';
import 'package:hash_wallet/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/action_row/coin_action_row.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/assets_history_section.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/cards/cards_view.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/mweb_ad.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/unconfirmed_balance_widget.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/wallet_info.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:hash_wallet/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart';
import 'package:hash_wallet/view_model/monero_account_list/monero_account_list_view_model.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import "dart:async";
import "package:hash_wallet/bitcoin/bitcoin.dart";
import "package:hash_wallet/core/auth_service.dart";
import "package:hash_wallet/di.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/main.dart";
import "package:hash_wallet/new-ui/modal_navigator.dart";
import "package:hash_wallet/new-ui/pages/card_customizer.dart";
import "package:hash_wallet/new-ui/pages/account_education_page.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/accounts_promo.dart";
import "package:hash_wallet/src/widgets/alert_with_one_action.dart";
import "package:cw_core/sync_status.dart";
import "package:hash_wallet/new-ui/pages/seed/seed_backup_reminder_page.dart";
import "package:hash_wallet/new-ui/pages/send_page.dart";
import "package:hash_wallet/new-ui/pages/settings_page.dart";
import "package:hash_wallet/new-ui/pages/wallet_accounts_page.dart";
import "package:hash_wallet/new-ui/widgets/buy_sell/buy_sell_selector_modal.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/action_row/coin_action_row.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/assets_history/assets_history_section.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/cards/balance_card.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/cards/cards_view.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/mweb_ad.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/seed_backup_reminder_card.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/unconfirmed_balance_widget.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/zcash_migration_modal.dart";
import "package:hash_wallet/routes.dart";
import "package:hash_wallet/utils/payment_request.dart";
import "package:hash_wallet/view_model/dashboard/dashboard_view_model.dart";
import "package:hash_wallet/view_model/dashboard/nft_view_model.dart";
import "package:cw_core/unspent_coin_type.dart";
import "package:cw_core/wallet_type.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:flutter_mobx/flutter_mobx.dart";
import "package:mobx/mobx.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
class NewHomePage extends StatefulWidget {
NewHomePage({super.key, required this.dashboardViewModel, required this.nftViewModel});
const NewHomePage({required this.dashboardViewModel, required this.nftViewModel, super.key});
final DashboardViewModel dashboardViewModel;
final NFTViewModel nftViewModel;
@@ -34,225 +47,302 @@ class NewHomePage extends StatefulWidget {
State<NewHomePage> createState() => _NewHomePageState();
}
class _NewHomePageState extends State<NewHomePage> {
MoneroAccountListViewModel? accountListViewModel;
bool _lightningMode = false;
class _NewHomePageState extends State<NewHomePage> with RouteAware {
late final ReactionDisposer _walletReaction;
late final ReactionDisposer _migrationReaction;
@override
void initState() {
super.initState();
_setAccountViewModel();
reaction((_) => widget.dashboardViewModel.wallet, (_) {
_setAccountViewModel();
setState(() {
_lightningMode = false;
});
_walletReaction = reaction((_) => widget.dashboardViewModel.wallet, (_) {
setState(() {});
});
_migrationReaction = reaction((_) => widget.dashboardViewModel.isMigratingToIronwood, (val) {
if (val && !widget.dashboardViewModel.settingsStore.zcashMigrationModalViewed) {
if (!context.mounted) {
return;
}
widget.dashboardViewModel.settingsStore.zcashMigrationModalViewed = true;
showMaterialModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => ZcashMigrationModal(
hasContinue: true,
balance: widget.dashboardViewModel.wallet.balance.values.first.unavailable
.toStringWithSymbol(),
),
);
}
});
}
void _setAccountViewModel() {
accountListViewModel = widget.dashboardViewModel.balanceViewModel.hasAccounts
? getIt.get<MoneroAccountListViewModel>()
: null;
@override
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
}
@override
Widget build(BuildContext context) {
return Container(
height: MediaQuery.of(context).size.height,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceDim,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
void dispose() {
_walletReaction();
_migrationReaction();
routeObserver.unsubscribe(this);
super.dispose();
}
@override
void didPopNext() => widget.dashboardViewModel.loadSeedBackupReminder();
@override
Widget build(BuildContext context) => Container(
height: MediaQuery.of(context).size.height,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceDim,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
child: Stack(
children: [
CustomScrollView(
physics: BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
slivers: [
SliverPadding(
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
sliver: CupertinoSliverRefreshControl(
refreshTriggerPullDistance: 160,
refreshIndicatorExtent: 90,
onRefresh: () => widget.dashboardViewModel.refreshDashboard(),
child: Stack(
children: [
CustomScrollView(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
slivers: [
SliverPadding(
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
sliver: CupertinoSliverRefreshControl(
refreshTriggerPullDistance: 160,
refreshIndicatorExtent: 90,
onRefresh: () {
unawaited(widget.nftViewModel.getNFTAssetByWallet());
return widget.dashboardViewModel.refreshDashboard();
},
),
),
),
SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start,
spacing: 24.0,
children: [
TopBar(
dashboardViewModel: widget.dashboardViewModel,
lightningMode: _lightningMode,
onLightningSwitchPress: () {
setState(() {
_lightningMode = !_lightningMode;
});
},
onSettingsButtonPress: () {
CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(85),
builder: (context) => FractionallySizedBox(
child: Material(
child: NewSettingsPage(
SliverToBoxAdapter(
child: Observer(
builder: (_) {
final _lightningMode = widget.dashboardViewModel.lightningMode;
return Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start,
spacing: 24.0,
children: [
TopBar(
key: ValueKey(widget.dashboardViewModel.wallet.id),
dashboardViewModel: widget.dashboardViewModel,
authService: getIt.get<AuthService>(),
))),
);
},
),
Observer(
builder: (_) => WalletInfoBar(
lightningMode: _lightningMode,
hardwareWalletType: widget.dashboardViewModel.wallet.hardwareWalletType,
name: widget.dashboardViewModel.wallet.name,
hasCustomize: accountListViewModel != null,
onCustomizeButtonTap: openAccountCustomizer),
),
Column(
children: [
Observer(
builder: (_) => CardsView(
key: ValueKey(widget.dashboardViewModel.wallet.name),
onCustomizeTapped: openCardCustomizer,
dashboardViewModel: widget.dashboardViewModel,
accountListViewModel: accountListViewModel,
onCompactModeBackgroundCardsTapped: openAccountCustomizer,
lightningMode: _lightningMode,
onLightningSwitchPress: () {
widget.dashboardViewModel.toggleLightningMode();
},
onSettingsButtonPress: () {
CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(85),
builder: (context) => FractionallySizedBox(
child: Material(
child: NewSettingsPage(
dashboardViewModel: widget.dashboardViewModel,
authService: getIt.get<AuthService>(),
),
),
),
).then((_) async {
widget.dashboardViewModel.accountListViewModel?.reload();
await Future<void>.delayed(Duration.zero);
await widget.dashboardViewModel.loadCardDesigns();
if (mounted) setState(() {});
});
},
),
),
Observer(builder: (_) {
return AnimatedSize(
duration: Duration(milliseconds: 150),
curve: Curves.easeInOutCubic,
child: (widget.dashboardViewModel.shouldShowBalanceHiddenMessage)
? Column(
children: [
SizedBox(
height: 12,
width: double.infinity,
),
Text(
S.of(context).long_press_show_balance,
style: TextStyle(
color:
Theme.of(context).colorScheme.onSurfaceVariant),
)
],
)
: SizedBox(width: double.infinity),
);
}),
UnconfirmedBalanceWidget(
dashboardViewModel: widget.dashboardViewModel,
),
],
),
Observer(
builder: (_) {
return Column(
children: [
CoinActionRow(
lightningMode: _lightningMode,
showSwap: widget.dashboardViewModel.isEnabledSwapAction,
walletType: widget.dashboardViewModel.wallet.type,
Column(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
layoutBuilder: (currentChild, previousChildren) => Stack(
// the front (selected) card sits at the bottom of the stack box
alignment: Alignment.bottomCenter,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
),
child: KeyedSubtree(
key: ValueKey(_lightningMode),
child: CardsView(
key: ValueKey(
"${widget.dashboardViewModel.wallet.name}_${_lightningMode}_${widget.dashboardViewModel.accountListViewModel?.accounts.length ?? 0}_${widget.dashboardViewModel.cardDesigns.length}"),
onCustomizeTapped: openCardCustomizer,
dashboardViewModel: widget.dashboardViewModel,
accountListViewModel:
widget.dashboardViewModel.accountListViewModel,
onCompactModeBackgroundCardsTapped: openAccountCustomizer,
lightningMode: _lightningMode),
),
),
Observer(
builder: (_) => AnimatedSize(
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOutCubic,
child: (widget.dashboardViewModel
.shouldShowBalanceHiddenMessage)
? Column(
children: [
const SizedBox(
height: 12,
width: double.infinity,
),
Text(
S.of(context).long_press_show_balance,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
)
],
)
: const SizedBox(width: double.infinity),
)),
UnconfirmedBalanceWidget(
dashboardViewModel: widget.dashboardViewModel),
SeedBackupReminderCard(
dashboardViewModel: widget.dashboardViewModel,
onTap: openSeedBackupReminder,
),
],
),
if (AccountsPromo.supportsWallet(widget.dashboardViewModel.wallet.type) &&
widget.dashboardViewModel.accountListViewModel != null &&
!_lightningMode)
AccountsPromo(
settingsStore: widget.dashboardViewModel.settingsStore,
walletName: walletTypeToString(widget.dashboardViewModel.wallet.type),
onTap: _openAccountsFromPromo,
),
MwebAd(
dashboardViewModel: widget.dashboardViewModel,
),
],
);
},
),
],
Column(
children: [
CoinActionRow(
lightningMode: _lightningMode,
showSwap: widget.dashboardViewModel.isEnabledSwapAction,
walletType: widget.dashboardViewModel.wallet.type,
),
MwebAd(
dashboardViewModel: widget.dashboardViewModel,
),
],
)
],
);
},
),
),
),
Observer(
builder: (_) => AssetsHistorySection(
nftViewModel: widget.nftViewModel,
dashboardViewModel: widget.dashboardViewModel,
Observer(
builder: (_) => AssetsHistorySection(
nftViewModel: widget.nftViewModel,
dashboardViewModel: widget.dashboardViewModel,
),
),
const SliverToBoxAdapter(
child: SizedBox(height: 80.0),
)
]),
Container(
height: (MediaQuery.of(context).padding.top),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: <Color>[
Theme.of(context).colorScheme.surface.withAlpha(5),
Theme.of(context).colorScheme.surface.withAlpha(25),
Theme.of(context).colorScheme.surface.withAlpha(50),
Theme.of(context).colorScheme.surface.withAlpha(100),
Theme.of(context).colorScheme.surface.withAlpha(150),
Theme.of(context).colorScheme.surface.withAlpha(175),
Theme.of(context).colorScheme.surface.withAlpha(200),
],
),
SliverToBoxAdapter(
child: SizedBox(height: 80.0),
)
]),
Container(
height: (MediaQuery.of(context).padding.top),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: <Color>[
Theme.of(context).colorScheme.surface.withAlpha(5),
Theme.of(context).colorScheme.surface.withAlpha(25),
Theme.of(context).colorScheme.surface.withAlpha(50),
Theme.of(context).colorScheme.surface.withAlpha(100),
Theme.of(context).colorScheme.surface.withAlpha(150),
Theme.of(context).colorScheme.surface.withAlpha(175),
Theme.of(context).colorScheme.surface.withAlpha(200),
],
),
),
),
],
],
),
);
Future<void> openCardCustomizer() async {
if (!_checkReadyToManage()) return;
await CardCustomizer.show(
context: context,
dashboardViewModel: widget.dashboardViewModel,
account: !widget.dashboardViewModel.lightningMode &&
widget.dashboardViewModel.isMultiAccountsEnabled
? widget.dashboardViewModel.accountListViewModel?.selectedAccount
: null,
lightningMode: widget.dashboardViewModel.lightningMode,
);
await widget.dashboardViewModel.accountListViewModel?.reload();
await widget.dashboardViewModel.loadCardDesigns();
}
void openSeedBackupReminder() {
Navigator.of(context).push(
CupertinoPageRoute<void>(
builder: (_) => SeedBackupReminderPage(
dashboardViewModel: widget.dashboardViewModel,
authService: getIt.get<AuthService>(),
),
),
);
}
void openAccountCustomizer() async {
Future<void> openAccountCustomizer() async {
if (widget.dashboardViewModel.accountListViewModel == null || !_checkReadyToManage()) return;
final page = getIt.get<WalletAccountsPage>(param1: widget.dashboardViewModel);
await CupertinoScaffold.showCupertinoModalBottomSheet(
barrierColor: Colors.black.withAlpha(60),
context: context,
builder: (context) {
return ModalNavigator(
parentContext: context,
heightMode: ModalHeightModes.fullScreen,
rootPage: Material(
child: AccountCustomizer(
accountListViewModel: accountListViewModel!,
accountEditOrCreateViewModel: getIt.get<MoneroAccountEditOrCreateViewModel>(),
dashboardViewModel: widget.dashboardViewModel,
)),
);
},
builder: (context) => ModalNavigator(
parentContext: context,
heightMode: ModalHeightModes.fullScreen,
rootPage: Material(
child: page,
),
),
);
widget.dashboardViewModel.loadCardDesigns();
await widget.dashboardViewModel.loadCardDesigns();
}
void openCardCustomizer() async {
final bloc = getIt.get<CardCustomizerBloc>(
param1: _lightningMode,
param2: widget.dashboardViewModel.settingsStore.displayAmountsInSatoshi);
await CupertinoScaffold.showCupertinoModalBottomSheet(
barrierColor: Colors.black.withAlpha(60),
context: context,
builder: (context) {
return ModalNavigator(
parentContext: context,
heightMode: ModalHeightModes.fullScreen,
rootPage: BlocProvider(
create: (context) => bloc,
child: Material(
child: CardCustomizer(
cryptoTitle: widget.dashboardViewModel.wallet.currency.fullName ??
widget.dashboardViewModel.wallet.currency.name,
cryptoName: widget.dashboardViewModel.wallet.currency.name,
)),
));
},
);
bloc.add(DesignSaved());
await bloc.stream.firstWhere((s) => s is CardCustomizerSaved);
widget.dashboardViewModel.loadCardDesigns();
Future<void> _openAccountsFromPromo() async {
if (!AccountsPromo.supportsWallet(widget.dashboardViewModel.wallet.type) ||
widget.dashboardViewModel.accountListViewModel == null || !_checkReadyToManage()) return;
await AccountEducationPage(
settingsStore: widget.dashboardViewModel.settingsStore,
).show(context);
if (mounted) await openAccountCustomizer();
}
bool _checkReadyToManage() {
if (widget.dashboardViewModel.status is! SyncedSyncStatus) {
showDialog(
context: context,
builder: (context) => AlertWithOneAction(
alertTitle: S.of(context).wallet_is_syncing,
alertContent: S.of(context).cannot_manage_accounts_during_sync,
buttonText: S.of(context).ok,
buttonAction: Navigator.of(context).pop,
),
);
return false;
}
return true;
}
}
@@ -68,6 +68,7 @@ class _LightningUsernamePageState extends State<LightningUsernamePage> {
ModalTopBar(
title: "Lightning ${S.of(context).username}",
leadingIcon: Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
@@ -111,6 +112,7 @@ class _LightningUsernamePageState extends State<LightningUsernamePage> {
),
SizedBox(),
NewPrimaryButton(
key: ValueKey("lightning_username_page_skip_button_key"),
onPressed: () {
Navigator.of(context).popUntil((route) => route.isFirst);
},
+182 -174
View File
@@ -1,50 +1,52 @@
import 'package:hash_wallet/core/utilities.dart';
import 'package:hash_wallet/entities/auto_generate_subaddress_status.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/payjoin_copy_modal.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_address_type.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_address_widget.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_amount_display.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_amount_modal.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_bottom_buttons.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_info_box.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_label_modal.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_label_widget.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_large_amount_preview.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_qr_code.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_token_display.dart';
import 'package:hash_wallet/utils/share_util.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/receive_option_view_model.dart';
import 'package:hash_wallet/view_model/wallet_address_list/wallet_address_list_item.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/payment_uris.dart';
import 'package:cw_core/receive_page_option.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import 'package:hash_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hash_wallet/bitcoin/bitcoin.dart';
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/anonpay/anonpay_donation_link_info.dart';
import 'package:hash_wallet/entities/preferences_key.dart';
import 'package:hash_wallet/src/screens/receive/anonpay_receive_page.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:hash_wallet/routes.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import "package:hash_wallet/anonpay/anonpay_donation_link_info.dart";
import "package:hash_wallet/bitcoin/bitcoin.dart";
import "package:hash_wallet/core/utilities.dart";
import "package:hash_wallet/di.dart";
import "package:hash_wallet/entities/auto_generate_subaddress_status.dart";
import "package:hash_wallet/entities/preferences_key.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/modern_button.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/payjoin_copy_modal.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_address_type.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_address_widget.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_amount_display.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_amount_modal.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_bottom_buttons.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_info_box.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_label_modal.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_label_widget.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_large_amount_preview.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_qr_code.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_token_display.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/routes.dart";
import "package:hash_wallet/utils/share_util.dart";
import "package:hash_wallet/view_model/dashboard/dashboard_view_model.dart";
import "package:hash_wallet/view_model/dashboard/receive_option_view_model.dart";
import "package:hash_wallet/view_model/wallet_address_list/wallet_address_list_item.dart";
import "package:hash_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/payment_uris.dart";
import "package:cw_core/receive_page_option.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_type.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_mobx/flutter_mobx.dart";
import "package:mobx/mobx.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
import "package:shared_preferences/shared_preferences.dart";
class NewReceivePage extends StatefulWidget {
NewReceivePage(
{super.key,
required this.addressListViewModel,
required this.receiveOptionViewModel,
required this.dashboardViewModel,
required this.lightningMode,
CryptoCurrency? initialCurrency}) {
NewReceivePage({
required this.addressListViewModel,
required this.receiveOptionViewModel,
required this.dashboardViewModel,
required this.lightningMode,
super.key,
CryptoCurrency? initialCurrency,
}) {
if (initialCurrency != null && initialCurrency != addressListViewModel.selectedCurrency) {
addressListViewModel.setTokenCurrency(initialCurrency);
}
@@ -69,82 +71,49 @@ class _NewReceivePageState extends State<NewReceivePage> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.lightningMode) {
widget.addressListViewModel.setTokenCurrency(CryptoCurrency.btcln);
widget.receiveOptionViewModel.selectReceiveOption(
widget.receiveOptionViewModel.options
.firstWhereOrNull((item) => item.value.contains("Lightning")) ??
ReceivePageOption.mainnet,
);
widget.addressListViewModel.setTokenCurrency(CryptoCurrency.btcln);
widget.addressListViewModel.selectedCurrency = CryptoCurrency.btcln;
} else if (widget.addressListViewModel.wallet.type == WalletType.bitcoin) {
widget.receiveOptionViewModel.selectReceiveOption(
widget.receiveOptionViewModel.options
.firstWhereOrNull((item) => item.value.contains("Standard")) ??
ReceivePageOption.mainnet,
);
widget.addressListViewModel.selectedCurrency = CryptoCurrency.btc;
}
});
reaction((_) => widget.addressListViewModel.uri, (newAddress) {
_reloadAddressWithLabel(newAddress);
});
reaction((_) => widget.addressListViewModel.uri, _reloadAddressWithLabel);
_addressItemWithLabel =
widget.addressListViewModel.forceRecomputeItems.firstWhereOrNull((item) {
return (item is WalletAddressListItem &&
item.address == widget.addressListViewModel.uri.address);
}) as WalletAddressListItem?;
_addressItemWithLabel = widget.addressListViewModel.forceRecomputeItems.firstWhereOrNull(
(item) =>
item is WalletAddressListItem && item.address == widget.addressListViewModel.uri.address,
) as WalletAddressListItem?;
reaction((_) => widget.receiveOptionViewModel.selectedReceiveOption, (option) {
// The infobox depends on the selected address type, so the page has to be
// rebuilt when it changes.
if (mounted) setState(() {});
reaction((_) => widget.receiveOptionViewModel.selectedReceiveOption,
(ReceivePageOption option) {
if (widget.dashboardViewModel.type == WalletType.bitcoin &&
bitcoin!.isBitcoinReceivePageOption(option)) {
widget.addressListViewModel.setAddressType(bitcoin!.getOptionToType(option));
if (option.value.contains("Lightning")) {
widget.addressListViewModel.selectedCurrency = CryptoCurrency.btcln;
} else {
widget.addressListViewModel.selectedCurrency = CryptoCurrency.btc;
}
return;
}
switch (option) {
case ReceivePageOption.anonPayInvoice:
Navigator.pushNamed(
context,
Routes.anonPayInvoicePage,
arguments: [widget.addressListViewModel.address.address, option],
);
break;
case ReceivePageOption.anonPayDonationLink:
final sharedPreferences = getIt.get<SharedPreferences>();
final clearnetUrl = sharedPreferences.getString(PreferencesKey.clearnetDonationLink);
final onionUrl = sharedPreferences.getString(PreferencesKey.onionDonationLink);
final donationWalletName =
sharedPreferences.getString(PreferencesKey.donationLinkWalletName);
if (clearnetUrl != null &&
onionUrl != null &&
widget.addressListViewModel.wallet.name == donationWalletName) {
Navigator.pushNamed(
context,
Routes.anonPayReceivePage,
arguments: AnonPayReceivePageArgs(
invoiceInfo: AnonpayDonationLinkInfo(
clearnetUrl: clearnetUrl,
onionUrl: onionUrl,
address: widget.addressListViewModel.address.address,
),
qrImage: widget.addressListViewModel.qrImage,
),
);
} else {
Navigator.pushNamed(
context,
Routes.anonPayInvoicePage,
arguments: [widget.addressListViewModel.address.address, option],
);
}
break;
default:
if ([WalletType.bitcoin, WalletType.litecoin]
.contains(widget.addressListViewModel.type)) {
widget.addressListViewModel.setAddressType(bitcoin!.getBitcoinAddressType(option));
}
if ([WalletType.bitcoin, WalletType.litecoin]
.contains(widget.addressListViewModel.type)) {
widget.addressListViewModel.setAddressType(bitcoin!.getBitcoinAddressType(option));
}
});
}
@@ -154,16 +123,19 @@ class _NewReceivePageState extends State<NewReceivePage> {
final hasAddressTypeSelector = widget.receiveOptionViewModel.options.length > 1;
final hasLabel = _addressItemWithLabel?.name != null && _addressItemWithLabel!.name!.isNotEmpty;
final infoboxDismissed = widget.addressListViewModel.wallet.walletInfo.receiveInfoboxDismissed;
final infobox = ReceiveInfoBox.forWalletType(widget.addressListViewModel.type,
supportedCurrencies: widget.addressListViewModel.tokenCurrencies
.whereType<CryptoCurrency>()
.toList(), onDismissed: () {
widget.addressListViewModel.dismissInfobox();
setState(() {});
},
autoGenerateSubaddressStatus: widget.lightningMode
? AutoGenerateSubaddressStatus.disabled
: widget.dashboardViewModel.settingsStore.autoGenerateSubaddressStatus);
final infobox = ReceiveInfoBox.forWalletType(
widget.addressListViewModel.type,
supportedCurrencies:
widget.addressListViewModel.tokenCurrencies.whereType<CryptoCurrency>().toList(),
onDismissed: () {
widget.addressListViewModel.dismissInfobox();
setState(() {});
},
autoGenerateSubaddressStatus: widget.lightningMode
? AutoGenerateSubaddressStatus.disabled
: widget.dashboardViewModel.settingsStore.autoGenerateSubaddressStatus,
addressRotates: _selectedAddressRotates,
);
return Container(
decoration: BoxDecoration(
@@ -175,9 +147,7 @@ class _NewReceivePageState extends State<NewReceivePage> {
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.vertical(
top: Radius.circular(24),
),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
child: Column(
@@ -186,30 +156,49 @@ class _NewReceivePageState extends State<NewReceivePage> {
children: [
ModalTopBar(
title: _largeQrMode ? "" : S.of(context).receive,
leadingIcon: Icon(Icons.close),
trailingIcon: _largeQrMode
? Icon(Icons.share)
: widget.addressListViewModel.hasAddressRotation
/* TODO rotating is broken on mweb, disabling for now, fix after mvp*/
&&
!(widget.receiveOptionViewModel.selectedReceiveOption.description ?? "")
.toLowerCase()
.contains("mweb")
? Icon(Icons.refresh)
: null,
leadingIcon: const Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: () {
Navigator.of(context, rootNavigator: true).pop();
},
onTrailingPressed: () {
if (_largeQrMode) {
ShareUtil.share(
text: widget.addressListViewModel.uri.toString(),
context: context,
);
} else if (widget.addressListViewModel.hasAddressRotation) {
widget.addressListViewModel.rotateAddress();
}
},
trailingWidget: Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _largeQrMode ||
widget.addressListViewModel.hasAddressRotation
/* TODO rotating is broken on mweb, disabling for now, fix after mvp*/
&&
!(widget.receiveOptionViewModel.selectedReceiveOption.description ??
"")
.toLowerCase()
.contains("mweb")
? ModernButton(
key: ValueKey(_largeQrMode),
size: 36,
icon: _largeQrMode
? const Icon(Icons.share)
: widget.addressListViewModel.isRotatingAddress
? const CupertinoActivityIndicator()
: const Icon(Icons.refresh),
semanticLabel: _largeQrMode
? S.of(context).share_address
: S.of(context).rotate_address,
onPressed: () {
if (_largeQrMode) {
ShareUtil.share(
text: widget.addressListViewModel.uri.toString(),
context: context,
);
} else {
if (widget.addressListViewModel.hasAddressRotation) {
widget.addressListViewModel.rotateAddress();
}
}
},
)
: const SizedBox.shrink(),
),
),
),
Expanded(
child: Column(
@@ -242,31 +231,45 @@ class _NewReceivePageState extends State<NewReceivePage> {
ReceiveAddressWidget(
addressListViewModel: widget.addressListViewModel,
),
GestureDetector(
onTap: _showLabelModal,
child: ReceiveLabelWidget(
name: _addressItemWithLabel?.name ?? "",
largeQrMode: _largeQrMode,
)),
// The label chip animates to zero height when there is no
// label (or in large QR mode); keep it out of the semantics
// tree entirely while it is collapsed.
ExcludeSemantics(
excluding: _largeQrMode || !hasLabel,
child: MergeSemantics(
child: Semantics(
button: true,
hint: S.of(context).set_label,
child: GestureDetector(
onTap: _showLabelModal,
child: ReceiveLabelWidget(
name: _addressItemWithLabel?.name ?? "",
largeQrMode: _largeQrMode,
),
),
),
),
),
Observer(
builder: (_) => ReceiveBottomButtons(
key: const ValueKey(0),
largeQrMode: _largeQrMode,
showAccountsButton: widget.addressListViewModel.hasAddressList,
showLabelButton: widget.addressListViewModel.hasAddressList && !hasLabel,
copyData: widget.addressListViewModel.hasPayjoin
? null
: ClipboardData(
text: widget.addressListViewModel.displayAmount.isEmpty
? widget.addressListViewModel.uri.address
: widget.addressListViewModel.uri.toString(),
),
onCopyButtonPressed: () {
if (widget.addressListViewModel.hasPayjoin) {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) =>
PayjoinCopyModal(uri: widget.addressListViewModel.uri));
} else {
Clipboard.setData(
ClipboardData(
text: widget.addressListViewModel.displayAmount.isEmpty
? widget.addressListViewModel.uri.address
: widget.addressListViewModel.uri.toString()),
isScrollControlled: true,
context: context,
builder: (context) =>
PayjoinCopyModal(uri: widget.addressListViewModel.uri),
);
}
},
@@ -275,12 +278,10 @@ class _NewReceivePageState extends State<NewReceivePage> {
context: context,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withAlpha(80),
builder: (context) {
return ReceiveAmountModal(
walletAddressListViewModel: widget.addressListViewModel,
onSubmitted: (amount) {},
);
},
builder: (context) => ReceiveAmountModal(
walletAddressListViewModel: widget.addressListViewModel,
onSubmitted: (amount) {},
),
);
},
onLabelButtonPressed: _showLabelModal,
@@ -293,22 +294,25 @@ class _NewReceivePageState extends State<NewReceivePage> {
),
),
ReceiveLargeAmountPreview(
amount: widget.addressListViewModel.displayAmount,
currency: widget.addressListViewModel.cryptoCurrencySymbol,
largeQrMode: _largeQrMode),
amount: widget.addressListViewModel.displayAmount,
currency: widget.addressListViewModel.cryptoCurrencySymbol,
largeQrMode: _largeQrMode,
),
if (infobox != null && !widget.addressListViewModel.isLightning)
ClipRect(
child: AnimatedAlign(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
heightFactor: infoboxDismissed ? 0 : 1,
alignment: Alignment.center,
child: AnimatedOpacity(
child: AnimatedAlign(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
heightFactor: infoboxDismissed ? 0 : 1,
alignment: Alignment.center,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: infoboxDismissed ? 0 : 1,
curve: Curves.easeOutCubic,
child: infobox),
))
child: infobox,
),
),
),
],
),
),
@@ -318,14 +322,18 @@ class _NewReceivePageState extends State<NewReceivePage> {
);
}
/// Zcash is the only wallet type that also offers static address types, so
/// the rotation notice must not be shown for it unless the disposable
/// transparent type is the one currently selected.
bool get _selectedAddressRotates => true;
void _showLabelModal() {
showMaterialModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withAlpha(80),
builder: (context) {
return getIt.get<ReceiveLabelModal>(param1: _addressItemWithLabel);
}).then((value) {
context: context,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withAlpha(80),
builder: (_) => getIt.get<ReceiveLabelModal>(param1: _addressItemWithLabel),
).then((value) {
_reloadAddressWithLabel(widget.addressListViewModel.uri);
});
}
@@ -334,8 +342,8 @@ class _NewReceivePageState extends State<NewReceivePage> {
// FIXME: viewmodel doesn't want to load address name here, so we make it. investigate why later
setState(() {
_addressItemWithLabel = widget.addressListViewModel.forceRecomputeItems.firstWhereOrNull(
(item) => (item is WalletAddressListItem && item.address == newAddress.address))
as WalletAddressListItem;
(item) => item is WalletAddressListItem && item.address == newAddress.address,
) as WalletAddressListItem?;
});
}
}
+637 -4
View File
@@ -1,10 +1,643 @@
import 'package:flutter/material.dart';
import 'dart:math';
import 'dart:ui';
class ScanPage extends StatelessWidget {
const ScanPage({super.key});
import 'package:hash_wallet/entities/qr_scanner.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/modern_button.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/new-ui/widgets/scan_page/network_list.dart';
import 'package:hash_wallet/new-ui/widgets/send_page/floating_icon_button.dart';
import 'package:hash_wallet/src/widgets/alert_with_one_action.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:fast_scanner/fast_scanner.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:ur/ur_decoder.dart';
class ScanPage extends StatefulWidget {
const ScanPage({super.key, this.showHelp = false, this.showManualInput = true});
final bool showHelp;
final bool showManualInput;
@override
State<ScanPage> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> {
final MobileScannerController controller = MobileScannerController();
int? _numCameras;
bool _frontFlashMode = false;
bool _textInputMode = false;
final TextEditingController textController = TextEditingController();
final FocusNode textFocusNode = FocusNode();
List<String> urCodes = [];
late var ur = URQRToURQRData(urCodes);
final decoder = URDecoder();
bool popped = false;
Barcode? _barcode;
@override
void initState() {
super.initState();
controller.addListener(() {
if (mounted) {
setState(() {
_numCameras = controller.value.availableCameras;
});
}
});
}
@override
void dispose() {
controller.dispose();
textController.dispose();
textFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Placeholder();
final double cutoutSize = MediaQuery.of(context).size.width * 0.8;
const double cutoutRadius = 24.0;
const Duration textModeSwitchDuration = Duration(milliseconds: 300);
final buttonColor = _frontFlashMode ? Colors.black.withAlpha(40) : Colors.white.withAlpha(40);
final buttonIconColor = _frontFlashMode ? Colors.black : Colors.white;
final isScanningURQR = decoder.processedPartsCount() > 0;
final double targetRadius = isScanningURQR ? (cutoutSize / 2) : cutoutRadius;
return Material(
child: Stack(
children: [
MobileScanner(
controller: controller,
onDetect: _handleBarcode,
),
// A full screen tap target would otherwise show up as one giant unlabeled
// node; leaving text mode is also possible with the back button.
Positioned.fill(
child: ExcludeSemantics(
child: GestureDetector(
onTap: () => setState(() {
_textInputMode = false;
}),
child: RepaintBoundary(
child: AnimatedSwitcher(
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInQuad,
duration: textModeSwitchDuration,
child: _textInputMode
? BackdropFilter(
key: ValueKey(1),
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(
color: _frontFlashMode ? Colors.white : Colors.black.withAlpha(153),
),
)
: TweenAnimationBuilder<double>(
key: const ValueKey(0),
tween: Tween<double>(begin: 0.0, end: targetRadius),
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutCubic,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(
color: _frontFlashMode ? Colors.white : Colors.black.withAlpha(153),
),
),
builder: (context, radius, child) {
return ClipPath(
clipper: HoleClipper(
width: cutoutSize,
height: cutoutSize,
radius: radius,
),
child: child,
);
},
),
),
),
),
),
),
AnimatedOpacity(
opacity: _textInputMode || isScanningURQR ? 0 : 1,
duration: textModeSwitchDuration,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: targetRadius),
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutCubic,
builder: (context, radius, child) => Center(
child: Container(
width: cutoutSize,
height: cutoutSize,
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 4.0),
borderRadius: BorderRadius.circular(radius),
),
),
),
),
),
SafeArea(
child: Column(
children: [
ModalTopBar(
title: "",
trailingWidget: AnimatedOpacity(
duration: textModeSwitchDuration,
opacity: _textInputMode ? 0 : 1,
child: Row(
spacing: 8,
children: [
if ((_numCameras ?? 0) > 1)
ModernButton.svg(
size: 36,
iconSize: 24,
svgPath: "assets/new-ui/camera_flip.svg",
onPressed: () {
controller.switchCamera();
setState(() {
_frontFlashMode = false;
});
},
iconColor: buttonIconColor,
backgroundColor: buttonColor,
semanticLabel: S.of(context).switch_camera,
),
ModernButton(
size: 36,
iconSize: 24,
icon: Icon(
(controller.value.torchState == TorchState.on || _frontFlashMode)
? Icons.flash_off_outlined
: Icons.flash_on),
semanticLabel:
(controller.value.torchState == TorchState.on || _frontFlashMode)
? S.of(context).turn_flash_off
: S.of(context).turn_flash_on,
onPressed: () {
if (controller.value.cameraDirection == CameraFacing.front) {
setState(() {
_frontFlashMode = !_frontFlashMode;
});
} else {
controller.toggleTorch();
}
},
iconColor: buttonIconColor,
backgroundColor: buttonColor,
),
],
),
),
leadingWidget: Row(
textBaseline: TextBaseline.ideographic,
spacing: 24,
children: [
ModernButton(
size: 36,
iconSize: 18,
icon: Icon(Icons.arrow_back_ios_new),
semanticLabel: S.of(context).seed_alert_back,
onPressed: () {
if (_textInputMode) {
setState(() {
_textInputMode = false;
});
} else {
Navigator.of(context).pop();
}
},
iconColor: buttonIconColor,
backgroundColor: buttonColor,
),
Text(
S.of(context).scan,
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w600, color: buttonIconColor),
)
],
),
)
],
),
),
Positioned(
bottom: 18 +
max(MediaQuery.of(context).viewInsets.bottom,
MediaQuery.of(context).viewPadding.bottom),
left: 16,
right: 16,
child: RepaintBoundary(
child: AnimatedOpacity(
opacity: _textInputMode ? 1 : 0,
duration: textModeSwitchDuration,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(18)),
child: Row(
spacing: 10,
children: [
Expanded(
child: MergeSemantics(
child: Semantics(
label: S.of(context).enter_code,
child: TextField(
enabled: _textInputMode,
controller: textController,
focusNode: textFocusNode,
onSubmitted: (val) {
if (val.isNotEmpty) {
Navigator.of(context).pop(val);
} else {
setState(() {
_textInputMode = false;
});
}
},
decoration: InputDecoration(hintText: S.of(context).enter_code),
),
),
),
),
FloatingIconButton(
iconPath: "assets/new-ui/paste.svg",
semanticLabel: S.of(context).paste,
onPressed: () async {
final data = await Clipboard.getData("text/plain");
if (data?.text != null) {
textController.text = data!.text!;
}
}),
SizedBox(
width: 2,
)
],
),
),
),
),
),
Positioned(
bottom: 120,
left: 0,
right: 0,
child: RepaintBoundary(
child: AnimatedOpacity(
duration: textModeSwitchDuration,
opacity: _textInputMode ? 0 : 1,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8,
children: [
// "not mvp"
// ScanPageButton(
// onTap: () async {
// FilePickerResult? res = await FilePicker.platform.pickFiles(
// type: FileType.image,
// allowMultiple: false,
// withData: false,
// );
//
// if (res != null && res.paths.isNotEmpty && res.paths.first != null) {
// final capture = await controller.analyzeImage(res.paths.first!);
// if (capture != null) {
// _handleBarcode(capture);
// }
// }
// },
// icon: Icons.photo_outlined,
// label: S.of(context).gallery,
// buttonColor: buttonColor,
// buttonIconColor: buttonIconColor),
if (widget.showManualInput)
ScanPageButton(
onTap: () {
setState(() {
_textInputMode = true;
});
Future.delayed(textModeSwitchDuration)
.then((val) => textFocusNode.requestFocus());
},
icon: Icons.edit_outlined,
label: S.of(context).manual_input,
buttonColor: buttonColor,
buttonIconColor: buttonIconColor),
if (widget.showHelp)
ScanPageButton(
onTap: () async {
if (_textInputMode) return;
try {
controller.stop();
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (context) => ScanPageNetworkList());
} finally {
controller.start();
}
},
icon: Icons.question_mark,
semanticsLabel: S.of(context).help,
buttonColor: buttonColor,
buttonIconColor: buttonIconColor)
],
),
),
)),
AnimatedOpacity(
duration: Duration(milliseconds: 500),
opacity: isScanningURQR ? 1 : 0,
child: Center(
child: SegmentedCircularProgress(
progress: URQrProgress(
expectedPartCount: decoder.expectedPartCount() ?? 0,
processedPartsCount: decoder.processedPartsCount(),
receivedPartIndexes: decoder.receivedPartIndexes().toList(),
percentage: decoder.estimatedPercentComplete(),
),
size: cutoutSize + 20,
activeColor: Theme.of(context).colorScheme.primary,
inactiveColor: Colors.white.withAlpha(102)),
),
),
Positioned(
bottom: 120,
left: 0,
right: 0,
child: IgnorePointer(
child: AnimatedOpacity(
duration: Duration(milliseconds: 500),
opacity: isScanningURQR ? 1 : 0,
// Three separate digits mean nothing on their own: announce the progress
// as one node, and only while a multi part code is actually being scanned.
child: ExcludeSemantics(
excluding: !isScanningURQR,
child: Semantics(
container: true,
liveRegion: true,
label: S.of(context).qr_parts_scanned(
"${decoder.processedPartsCount()}", "${decoder.expectedPartCount() ?? 0}"),
excludeSemantics: true,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"${decoder.processedPartsCount()}",
style: TextStyle(
fontSize: 45,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary),
),
Text(
"/",
style: TextStyle(
fontSize: 45, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
Text(
"${decoder.expectedPartCount()}",
style: TextStyle(
fontSize: 45, color: Theme.of(context).colorScheme.onSurface),
),
],
),
),
),
),
),
)
],
),
);
}
void _handleBarcode(BarcodeCapture barcodes) {
try {
_handleBarcodeInternal(barcodes);
} catch (e, st) {
showPopUp<void>(
context: context,
builder: (context) {
return AlertWithOneAction(
alertTitle: S.of(context).error,
alertContent: S.of(context).error_dialog_content,
buttonText: S.of(context).ok,
buttonAction: () {
Navigator.of(context).pop();
},
);
},
);
printV("$e\n$st");
}
}
void _handleBarcodeInternal(BarcodeCapture barcodes) {
for (final barcode in barcodes.barcodes) {
if (barcode.rawValue?.trim().isEmpty ?? false == false) continue;
if (barcode.rawValue!.startsWith("ur:")) {
if (urCodes.contains(barcode.rawValue)) continue;
decoder.receivePart(barcode.rawValue!);
setState(() {
urCodes.add(barcode.rawValue!);
ur = URQRToURQRData(urCodes);
});
if (decoder.estimatedPercentComplete() == 1) {
setState(() {
popped = true;
});
SchedulerBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pop(ur.inputs.join("\n"));
});
}
;
}
}
if (urCodes.isNotEmpty) return;
if (mounted) {
setState(() {
_barcode = barcodes.barcodes.firstOrNull;
});
if (_barcode != null && popped != true) {
setState(() {
popped = true;
});
Navigator.of(context).pop(_barcode!.rawValue ?? _barcode!.rawBytes);
}
}
}
}
class ScanPageButton extends StatelessWidget {
const ScanPageButton(
{super.key,
required this.onTap,
required this.icon,
this.label,
this.semanticsLabel,
required this.buttonColor,
required this.buttonIconColor});
final VoidCallback onTap;
final IconData icon;
final String? label;
/// Name for the icon-only variant, which has no visible [label].
final String? semanticsLabel;
final Color buttonColor;
final Color buttonIconColor;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
enabled: true,
label: semanticsLabel ?? label,
onTap: onTap,
excludeSemantics: true,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
decoration:
BoxDecoration(color: buttonColor, borderRadius: BorderRadius.circular(99999)),
child: Padding(
padding: EdgeInsets.only(
top: 10,
bottom: 10,
left: label == null ? 10 : 16,
right: label == null ? 10 : 20),
child: Row(
spacing: 10,
children: [
Icon(icon, size: 28, color: buttonIconColor),
if (label != null)
Text(label!,
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w500, color: buttonIconColor))
],
),
),
)));
}
}
class HoleClipper extends CustomClipper<Path> {
final double width;
final double height;
final double radius;
HoleClipper({
required this.width,
required this.height,
required this.radius,
});
@override
Path getClip(Size size) {
final Path fullScreenPath = Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
final Path cutoutPath = Path()
..addRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(size.width / 2, size.height / 2),
width: width,
height: height,
),
Radius.circular(radius),
),
);
return Path.combine(
PathOperation.difference,
fullScreenPath,
cutoutPath,
);
}
@override
bool shouldReclip(covariant HoleClipper oldClipper) {
return oldClipper.width != width || oldClipper.height != height || oldClipper.radius != radius;
}
}
class SegmentedCircularProgress extends StatelessWidget {
final URQrProgress progress;
final Color activeColor;
final Color inactiveColor;
final double size;
const SegmentedCircularProgress({
Key? key,
required this.progress,
required this.activeColor,
required this.inactiveColor,
required this.size,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(size, size),
painter: _SegmentedCirclePainter(
progress: progress,
activeColor: activeColor,
inactiveColor: inactiveColor,
),
);
}
}
class _SegmentedCirclePainter extends CustomPainter {
final URQrProgress progress;
static const double strokeWidth = 10;
static const double gapAngle = 0.08;
final Color activeColor;
final Color inactiveColor;
_SegmentedCirclePainter({
required this.progress,
required this.activeColor,
required this.inactiveColor,
});
@override
void paint(Canvas canvas, Size size) {
final rect = Rect.fromLTWH(0, 0, size.width, size.height);
final totalSegments = progress.expectedPartCount;
final sweepAngle = (2 * pi - (gapAngle * totalSegments)) / totalSegments;
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.butt;
double startAngle = -pi / 2 + (gapAngle / 2);
for (int i = 0; i < totalSegments; i++) {
bool isHighlighted = progress.receivedPartIndexes.contains(i);
paint.color = isHighlighted ? activeColor : inactiveColor;
canvas.drawArc(rect, startAngle, sweepAngle, false, paint);
startAngle += sweepAngle + gapAngle;
}
}
@override
bool shouldRepaint(covariant _SegmentedCirclePainter oldDelegate) {
return !oldDelegate.progress.equals(progress) ||
oldDelegate.progress.expectedPartCount != progress.expectedPartCount;
}
}
@@ -0,0 +1,53 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_page_header.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_page_scaffold.dart";
import "package:hash_wallet/new-ui/widgets/stacked_buttons.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:flutter/material.dart";
class DismissSeedVerificationPage extends StatelessWidget {
const DismissSeedVerificationPage({required this.onConfirm, super.key});
final VoidCallback onConfirm;
@override
Widget build(BuildContext context) => SeedPageScaffold(
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 295),
child: SeedPageHeader(
image: const HashImageWidget(
imageUrl: "assets/new-ui/warning_triangle.svg",
width: 125,
height: 111.72,
),
imageSpacing: 48,
titleSpacing: 24,
title: S.of(context).dismiss_verification_title,
description: Text.rich(
TextSpan(
children: [
TextSpan(text: "${S.of(context).dismiss_verification_description}\n"),
TextSpan(
text: S.of(context).seed_display_path,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
],
),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
letterSpacing: -0.07,
),
),
),
),
footer: StackedButtons(
primaryKey: const ValueKey("dismiss_seed_verification_page_confirm_button_key"),
primaryText: S.of(context).dismiss_verification_confirm,
onPrimary: onConfirm,
secondaryKey: const ValueKey("dismiss_seed_verification_page_back_button_key"),
secondaryText: S.of(context).seed_alert_back,
onSecondary: () => Navigator.of(context).pop(),
),
);
}
@@ -0,0 +1,17 @@
import "package:hash_wallet/routes.dart";
import "package:hash_wallet/utils/feature_flag.dart";
import "package:cw_core/wallet_type.dart";
import "package:flutter/widgets.dart";
void openWalletAfterSeedFlow(BuildContext context, WalletType walletType) {
if (FeatureFlag.isLightningEnabled && walletType == WalletType.bitcoin) {
Navigator.of(context).pushNamedAndRemoveUntil(
Routes.lightningUsernamePage,
(route) => route.isFirst,
arguments: true,
);
return;
}
Navigator.of(context).popUntil((route) => route.isFirst);
}
+112
View File
@@ -0,0 +1,112 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/pages/seed/open_wallet_after_seed_flow.dart";
import "package:hash_wallet/new-ui/pages/seed/skip_seed_verification_page.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_checkbox_row.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_hero_image.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_page_header.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_page_scaffold.dart";
import "package:hash_wallet/new-ui/widgets/stacked_buttons.dart";
import "package:hash_wallet/routes.dart";
import "package:hash_wallet/themes/core/theme_extension.dart";
import "package:cw_core/wallet_base.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
class PreSeedPage extends StatefulWidget {
const PreSeedPage(this.wallet, {super.key});
final WalletBase wallet;
@override
State<PreSeedPage> createState() => _PreSeedPageState();
}
class _PreSeedPageState extends State<PreSeedPage> {
bool _understandsOnlyWayToRecover = false;
bool _willWriteItDown = false;
bool _willNeverShareIt = false;
bool get _hasCheckedAll => _understandsOnlyWayToRecover && _willWriteItDown && _willNeverShareIt;
@override
Widget build(BuildContext context) => PopScope(
canPop: false,
child: SeedPageScaffold(
content: Column(
mainAxisSize: MainAxisSize.min,
spacing: 24,
children: [
SeedPageHeader(
image: const SeedHeroImage(),
title: S.of(context).recovery_phrase_intro_title,
description: Text(
S.of(context).recovery_phrase_intro_description,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
letterSpacing: -0.07,
),
),
),
Column(
spacing: 12,
children: [
SeedCheckboxRow(
key: const ValueKey("pre_seed_page_only_way_checkbox_key"),
iconPath: "assets/new-ui/key.svg",
text: S.of(context).recovery_phrase_check_only_way,
isChecked: _understandsOnlyWayToRecover,
onChanged: (value) => setState(() => _understandsOnlyWayToRecover = value),
),
SeedCheckboxRow(
key: const ValueKey("pre_seed_page_write_down_checkbox_key"),
iconPath: "assets/new-ui/set-amount.svg",
text: S.of(context).recovery_phrase_check_write_down,
isChecked: _willWriteItDown,
onChanged: (value) => setState(() => _willWriteItDown = value),
),
SeedCheckboxRow(
key: const ValueKey("pre_seed_page_never_share_checkbox_key"),
iconPath: "assets/new-ui/visibility.svg",
text: S.of(context).recovery_phrase_check_never_share,
isChecked: _willNeverShareIt,
onChanged: (value) => setState(() => _willNeverShareIt = value),
),
],
),
if (!_hasCheckedAll)
Text(
S.of(context).recovery_phrase_check_all_to_proceed,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.customColors.warningOutlineColor,
letterSpacing: -0.07,
),
),
],
),
footer: _hasCheckedAll
? StackedButtons(
primaryKey: const ValueKey("pre_seed_page_button_key"),
primaryText: S.of(context).show_recovery_phrase,
onPrimary: () => Navigator.of(context).pushNamed(Routes.seed, arguments: true),
secondaryKey: const ValueKey("pre_seed_page_skip_button_key"),
secondaryText: S.of(context).skip_this_step,
onSecondary: _confirmSkip,
secondaryAsLink: true,
)
: null,
alignTop: true,
),
);
void _confirmSkip() {
Navigator.of(context).push(
CupertinoPageRoute<void>(
builder: (_) => SkipSeedVerificationPage(
onConfirm: () => openWalletAfterSeedFlow(context, widget.wallet.type),
),
),
);
}
}
@@ -0,0 +1,67 @@
import "package:hash_wallet/core/auth_service.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/pages/seed/dismiss_seed_verification_page.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_hero_image.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_page_header.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_page_scaffold.dart";
import "package:hash_wallet/new-ui/widgets/stacked_buttons.dart";
import "package:hash_wallet/routes.dart";
import "package:hash_wallet/view_model/dashboard/dashboard_view_model.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
class SeedBackupReminderPage extends StatelessWidget {
const SeedBackupReminderPage({
required this.dashboardViewModel,
required this.authService,
super.key,
});
final DashboardViewModel dashboardViewModel;
final AuthService authService;
@override
Widget build(BuildContext context) => SeedPageScaffold(
content: SeedPageHeader(
image: const SeedHeroImage(),
title: S.of(context).recovery_phrase_intro_title,
description: Text(
S.of(context).recovery_phrase_intro_description,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
letterSpacing: -0.07,
),
),
),
footer: StackedButtons(
primaryKey: const ValueKey("seed_backup_reminder_page_button_key"),
primaryText: S.of(context).show_recovery_phrase,
onPrimary: () => authService.authenticateAction(
context,
route: Routes.seed,
arguments: false,
conditionToDetermineIfToUse2FA: dashboardViewModel
.settingsStore.shouldRequireTOTP2FAForAllSecurityAndBackupSettings,
),
secondaryKey: const ValueKey("seed_backup_reminder_page_dismiss_button_key"),
secondaryText: S.of(context).dismiss_this_step,
onSecondary: () => Navigator.of(context).push(
CupertinoPageRoute<void>(
builder: (_) => DismissSeedVerificationPage(onConfirm: () => _dismiss(context)),
),
),
secondaryAsLink: true,
),
);
Future<void> _dismiss(BuildContext context) async {
await dashboardViewModel.dismissSeedBackupReminder();
if (!context.mounted) {
return;
}
Navigator.of(context).popUntil((route) => route.isFirst);
}
}
@@ -0,0 +1,58 @@
import "package:hash_wallet/core/auth_service.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_hero_image.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_page_header.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_page_scaffold.dart";
import "package:hash_wallet/new-ui/widgets/stacked_buttons.dart";
import "package:hash_wallet/routes.dart";
import "package:hash_wallet/store/settings_store.dart";
import "package:hash_wallet/themes/core/theme_extension.dart";
import "package:flutter/material.dart";
class ShowKeysDisclaimerPage extends StatelessWidget {
const ShowKeysDisclaimerPage({
required this.authService,
required this.settingsStore,
super.key,
});
final AuthService authService;
final SettingsStore settingsStore;
@override
Widget build(BuildContext context) => SeedPageScaffold(
content: SeedPageHeader(
image: const SeedHeroImage(showWarningBadge: true),
title: S.of(context).secret_information_ahead,
description: Text.rich(
TextSpan(
children: [
TextSpan(text: "${S.of(context).secret_information_ahead_description}\n\n"),
TextSpan(
text: S.of(context).secret_information_ahead_warning,
style: TextStyle(color: context.customColors.warningOutlineColor),
),
],
),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
letterSpacing: -0.07,
),
),
),
footer: StackedButtons(
primaryKey: const ValueKey("show_keys_disclaimer_page_button_key"),
primaryText: S.of(context).show_recovery_phrase_and_keys,
onPrimary: () => authService.authenticateAction(
context,
route: Routes.showKeys,
conditionToDetermineIfToUse2FA:
settingsStore.shouldRequireTOTP2FAForAllSecurityAndBackupSettings,
),
secondaryKey: const ValueKey("show_keys_disclaimer_page_back_button_key"),
secondaryText: S.of(context).seed_alert_back,
onSecondary: () => Navigator.of(context).pop(),
),
);
}
@@ -0,0 +1,59 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_page_header.dart";
import "package:hash_wallet/new-ui/widgets/seed/seed_page_scaffold.dart";
import "package:hash_wallet/new-ui/widgets/stacked_buttons.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/themes/core/theme_extension.dart";
import "package:flutter/material.dart";
class SkipSeedVerificationPage extends StatelessWidget {
const SkipSeedVerificationPage({required this.onConfirm, super.key});
final VoidCallback onConfirm;
@override
Widget build(BuildContext context) => SeedPageScaffold(
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 295),
child: SeedPageHeader(
image: const HashImageWidget(
imageUrl: "assets/new-ui/warning_triangle.svg",
width: 125,
height: 111.72,
),
imageSpacing: 48,
titleSpacing: 24,
title: S.of(context).skip_verification_title,
description: Column(
spacing: 24,
children: [
Text(
S.of(context).skip_verification_warning,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.customColors.warningOutlineColor,
letterSpacing: -0.07,
),
),
Text(
S.of(context).skip_verification_question,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
letterSpacing: -0.07,
),
),
],
),
),
),
footer: StackedButtons(
primaryKey: const ValueKey("skip_seed_verification_page_confirm_button_key"),
primaryText: S.of(context).skip_verification_confirm,
onPrimary: onConfirm,
secondaryKey: const ValueKey("skip_seed_verification_page_back_button_key"),
secondaryText: S.of(context).seed_alert_back,
onSecondary: () => Navigator.of(context).pop(),
),
);
}
File diff suppressed because it is too large Load Diff
+414 -138
View File
@@ -1,109 +1,221 @@
import 'package:hash_wallet/core/auth_service.dart';
import 'package:hash_wallet/entities/new_ui_entities/list_item/list_item.dart';
import 'package:hash_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/modal_navigator.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import "package:hash_wallet/core/auth_service.dart";
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item.dart";
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/modal_navigator.dart";
import "package:hash_wallet/new-ui/pages/coin_control_page.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/routes.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/src/widgets/new_list_row/new_list_section.dart";
import "package:hash_wallet/view_model/dashboard/dashboard_view_model.dart";
import "package:cw_core/wallet_info.dart";
import "package:cw_core/wallet_type.dart";
import "package:flutter/material.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
bool _trueFunc(DashboardViewModel _) => true;
bool _falseFunc(DashboardViewModel _) => false;
bool _isBtc(DashboardViewModel vm) => vm.wallet.type == WalletType.bitcoin;
bool _alwaysVisible(DashboardViewModel _) => true;
bool _hasLightning(DashboardViewModel vm) => vm.hasLightning;
bool _hasSilentPayments(DashboardViewModel vm) => vm.hasSilentPayments;
bool _hasMweb(DashboardViewModel vm) => vm.hasMweb;
bool _hasWalletConnect(DashboardViewModel vm) => vm.hasWalletConnect;
bool _isCupcake(DashboardViewModel vm) =>
vm.wallet.hardwareWalletType == HardwareWalletType.cupcake;
bool _hasAccounts(DashboardViewModel vm) => vm.wallet.hasAccountsSupport;
bool _hasCoinControl(DashboardViewModel vm) => vm.wallet.hasCoinControl;
bool _requiresKeyImageSync(DashboardViewModel vm) =>
vm.wallet.type == WalletType.monero &&
[HardwareWalletType.cupcake, HardwareWalletType.trezor].contains(vm.wallet.hardwareWalletType);
class SettingsListItem {
const SettingsListItem(
this.iconPath,
this.title,
this.route, {
this.isCore = false,
this.requireAuth = false,
this.use2fa = _neverUse2fa,
this.condition = _alwaysVisible,
this.routeArgs,
this.routeArgsBuilder,
});
static bool _neverUse2fa(DashboardViewModel _) => false;
final String iconPath;
final String title;
final String route;
final bool isCore;
final Object? routeArgs;
final Object? Function(DashboardViewModel)? routeArgsBuilder;
final bool requireAuth;
final bool Function(DashboardViewModel) use2fa;
final bool Function(DashboardViewModel) condition;
const SettingsListItem(this.iconPath, this.title, this.route,
{this.routeArgs = null,
this.requireAuth = false,
this.use2fa = _falseFunc,
this.condition = _trueFunc});
}
class SettingsSectionData {
const SettingsSectionData(
this.title,
this.titleIconPath,
this.groups, {
this.titleColor,
this.headerSpacing = 16,
});
final String title;
final String titleIconPath;
final List<SettingsListItem> items;
final Map<String, List<ListItem>> groups;
final Color? titleColor;
final double headerSpacing;
}
const SettingsSectionData(this.title, this.titleIconPath, this.items);
class WalletSettingsResolver {
const WalletSettingsResolver();
static SettingsSectionData walletSettings =
SettingsSectionData(S.current.wallet_settings, "assets/new-ui/wallet-setting.svg", [
SettingsListItem(
"assets/new-ui/settings_row_icons/nodes.svg", S.current.nodes, Routes.manageNodes),
SettingsListItem(
"assets/new-ui/settings_row_icons/privacy.svg", S.current.privacy, Routes.privacyPage),
SettingsListItem(
"assets/new-ui/settings_row_icons/seed.svg", S.current.seed_and_keys, Routes.showKeys,
routeArgs: true,
requireAuth: true,
use2fa: (vm) => vm.settingsStore.shouldRequireTOTP2FAForAllSecurityAndBackupSettings),
SettingsListItem("assets/new-ui/settings_row_icons/lightning_username.svg",
"Lightning ${S.current.username}", Routes.lightningUsernamePage,
condition: _hasLightning),
SettingsListItem("assets/new-ui/settings_row_icons/wc.svg", S.current.walletConnect,
String titleFor(WalletType walletType, S strings) =>
'${walletTypeToString(walletType)} ${strings.settings_title}'.trim();
String iconPathFor(WalletType walletType) =>
"assets/new-ui/network_icons/${walletType.name.toLowerCase()}.svg";
List<List<SettingsListItem>> resolveSections(DashboardViewModel viewModel) {
final visibleItems = <SettingsListItem>[
SettingsListItem(
"assets/new-ui/settings_row_icons/accounts.svg",
viewModel.wallet.type == WalletType.bitcoin ? S.current.accounts_onchain : S.current.accounts,
Routes.walletAccountsPage,
isCore: true,
condition: _hasAccounts,
routeArgsBuilder: (vm) => vm,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/nodes.svg",
S.current.nodes,
Routes.manageNodes,
isCore: true,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/coin-control.svg",
S.current.coin_control_settings,
Routes.unspentCoinsList,
isCore: true,
condition: _hasCoinControl,
routeArgs: const CoinControlPageArgs(canEdit: false),
),
SettingsListItem(
"assets/new-ui/settings_row_icons/lightning_username.svg",
"Lightning ${S.current.username}",
Routes.lightningUsernamePage,
condition: _hasLightning,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/silent-payments.svg",
S.current.silent_payments,
Routes.silentPaymentsSettings,
condition: _hasSilentPayments,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/mweb.svg",
S.current.litecoin_mweb,
Routes.mwebSettings,
condition: _hasMweb,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/wc.svg",
S.current.walletConnect,
Routes.walletConnectConnectionsListing,
condition: _hasWalletConnect),
//SettingsListItem("assets/new-ui/settings_row_icons/silent-payments.svg", S.current.silent_payments_settings, Routes.silentPaymentsSettings, condition: _isBtc),
//SettingsListItem("assets/new-ui/settings_row_icons/mweb.svg", S.current.litecoin_mweb_settings, Routes.mwebSettings, condition: _hasMweb),
SettingsListItem("assets/new-ui/settings_row_icons/cupcake.svg", S.current.export_outputs,
Routes.urqrAnimatedPage,
routeArgs: {'export-outputs': 'export-outputs'}, condition: _isCupcake),
SettingsListItem(
"assets/new-ui/settings_row_icons/other.svg", S.current.other, Routes.otherSettingsPage),
]);
condition: _hasWalletConnect,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/sync-balance.svg",
S.current.resync_device,
Routes.syncKeyImagesDevices,
routeArgs: const {"export-outputs": "export-outputs"},
condition: _requiresKeyImageSync,
),
].where((item) => item.condition(viewModel)).toList();
static SettingsSectionData appSettings =
SettingsSectionData(S.current.app_settings, "assets/new-ui/hash-setting.svg", [
SettingsListItem("assets/new-ui/settings_row_icons/connections.svg", S.current.connections,
Routes.connectionSync),
// SettingsListItem("assets/new-ui/settings_row_icons/defaults.svg", "Defaults", ""),
SettingsListItem("assets/new-ui/settings_row_icons/display.svg", S.current.display,
Routes.displaySettingsPage),
SettingsListItem("assets/new-ui/settings_row_icons/security.svg", S.current.security,
Routes.securityBackupPage),
SettingsListItem("assets/new-ui/settings_row_icons/backup.svg", S.current.backup, Routes.backup,
requireAuth: true,
use2fa: (vm) => vm.settingsStore.shouldRequireTOTP2FAForAllSecurityAndBackupSettings),
]);
final coreItems = visibleItems.where((item) => item.isCore).toList();
final featureItems = visibleItems.where((item) => !item.isCore).toList();
static SettingsSectionData otherSettings = SettingsSectionData("", "", [
SettingsListItem(
"assets/new-ui/settings_row_icons/support.svg", S.current.settings_support, Routes.support),
SettingsListItem(
"assets/new-ui/settings_row_icons/info.svg", S.current.about, Routes.aboutPage),
]);
return [
if (coreItems.isNotEmpty) coreItems,
if (featureItems.isNotEmpty) featureItems,
];
}
}
static List<SettingsSectionData> all = [walletSettings, appSettings, otherSettings];
class SettingsPageSectionsResolver {
const SettingsPageSectionsResolver();
List<SettingsListItem> walletGeneral(S strings) => [
SettingsListItem(
'assets/new-ui/settings_row_icons/privacy.svg',
strings.privacy,
Routes.privacyPage,
),
SettingsListItem(
'assets/new-ui/settings_row_icons/seed.svg',
strings.recovery_and_keys,
Routes.showKeysDisclaimer,
),
SettingsListItem(
'assets/new-ui/settings_row_icons/other.svg',
strings.other,
Routes.otherSettingsPage,
),
];
List<SettingsListItem> appSettings() => [
SettingsListItem(
"assets/new-ui/settings_row_icons/connections.svg",
S.current.connections,
Routes.connectionSync,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/display.svg",
S.current.display,
Routes.displaySettingsPage,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/security.svg",
S.current.security,
Routes.securityBackupPage,
),
SettingsListItem(
"assets/new-ui/settings_row_icons/backup.svg",
S.current.backup,
Routes.backup,
requireAuth: true,
use2fa: (vm) => vm.settingsStore.shouldRequireTOTP2FAForAllSecurityAndBackupSettings,
),
];
List<SettingsListItem> supportAndAbout(S strings) => [
SettingsListItem(
'assets/new-ui/settings_row_icons/support.svg',
strings.settings_support,
Routes.support,
),
SettingsListItem(
'assets/new-ui/settings_row_icons/info.svg',
strings.about,
Routes.aboutPage,
),
];
}
class NewSettingsPage extends StatelessWidget {
const NewSettingsPage({super.key, required this.dashboardViewModel, required this.authService});
const NewSettingsPage({
super.key,
required this.dashboardViewModel,
required this.authService,
});
final DashboardViewModel dashboardViewModel;
final AuthService authService;
@@ -111,84 +223,248 @@ class NewSettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ModalNavigator(
parentContext: context,
rootPage: SettingsMainPage(
dashboardViewModel: dashboardViewModel,
authService: authService,
));
parentContext: context,
rootPage: SettingsMainPage(
dashboardViewModel: dashboardViewModel,
authService: authService,
),
);
}
}
class SettingsMainPage extends StatelessWidget {
const SettingsMainPage({super.key, required this.dashboardViewModel, required this.authService});
const SettingsMainPage({
super.key,
required this.dashboardViewModel,
required this.authService,
});
static const _walletSettingsResolver = WalletSettingsResolver();
static const _sectionsResolver = SettingsPageSectionsResolver();
final DashboardViewModel dashboardViewModel;
final AuthService authService;
@override
Widget build(BuildContext context) => Container(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
ModalTopBar(
title: S.of(context).settings_title,
leadingIcon: const Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: Navigator.of(context, rootNavigator: true).pop,
),
Expanded(
child: ListView(
controller: ModalScrollController.of(context),
padding: const EdgeInsets.fromLTRB(18, 0, 18, 32),
children: [
for (final (index, section) in _sections(context).indexed) ...[
if (index > 0) ...[
const SizedBox(height: 24),
Divider(color: Theme.of(context).colorScheme.surfaceContainerHighest),
const SizedBox(height: 24),
],
_SettingsSectionHeader(
iconPath: section.titleIconPath,
title: section.title,
foregroundColor: section.titleColor,
),
SizedBox(height: section.headerSpacing),
for (final (groupIndex, group) in section.groups.entries.indexed) ...[
if (groupIndex > 0) const SizedBox(height: 24),
NewListSections(sections: {group.key: group.value}),
],
],
],
),
),
],
),
);
List<SettingsSectionData> _sections(BuildContext context) => [
SettingsSectionData(
dashboardViewModel.wallet.name,
"assets/new-ui/wallet-setting.svg",
{
"wallet_type_settings": [
ListItemRegularRow(
keyValue: "wallet_type_settings",
label: _walletSettingsResolver.titleFor(
dashboardViewModel.wallet.type,
S.of(context),
),
iconPath: _walletSettingsResolver.iconPathFor(dashboardViewModel.wallet.type),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => WalletSettingsPage(
dashboardViewModel: dashboardViewModel,
authService: authService,
),
),
),
),
],
"wallet_general": _buildRows(context, _sectionsResolver.walletGeneral(S.of(context))),
},
titleColor: Theme.of(context).colorScheme.primary,
headerSpacing: 20,
),
SettingsSectionData(
S.of(context).app_settings,
"assets/new-ui/hash-setting.svg",
{
"app_settings": _buildRows(context, _sectionsResolver.appSettings()),
"support_and_about":
_buildRows(context, _sectionsResolver.supportAndAbout(S.of(context))),
},
),
];
List<ListItem> _buildRows(BuildContext context, List<SettingsListItem> items) => items
.where((item) => item.condition(dashboardViewModel))
.map((item) => _buildRow(context, item))
.toList();
ListItemRegularRow _buildRow(BuildContext context, SettingsListItem item) => ListItemRegularRow(
keyValue: item.route,
label: item.title,
iconPath: item.iconPath,
onTap: () => _openItem(context, item),
);
Future<void> _openItem(BuildContext context, SettingsListItem item) async {
final arguments = item.routeArgsBuilder?.call(dashboardViewModel) ?? item.routeArgs;
if (item.requireAuth) {
await authService.authenticateAction(
context,
conditionToDetermineIfToUse2FA: item.use2fa(dashboardViewModel),
route: item.route,
arguments: arguments,
);
return;
}
await Navigator.of(context).pushNamed(item.route, arguments: arguments);
}
}
class WalletSettingsPage extends StatelessWidget {
const WalletSettingsPage({
super.key,
required this.dashboardViewModel,
required this.authService,
});
static const _resolver = WalletSettingsResolver();
final DashboardViewModel dashboardViewModel;
final AuthService authService;
@override
Widget build(BuildContext context) {
List<ListItem> buildItems(SettingsSectionData section) => section.items
.map((item) => item.condition(dashboardViewModel)
? ListItemRegularRow(
keyValue: item.title,
label: item.title,
iconPath: item.iconPath,
onTap: () {
if (item.route.isNotEmpty) {
if (item.requireAuth) {
authService.authenticateAction(context,
conditionToDetermineIfToUse2FA: item.use2fa(dashboardViewModel),
route: item.route);
} else {
Navigator.of(context).pushNamed(item.route, arguments: item.routeArgs);
}
}
})
: null)
.whereType<ListItem>()
.toList();
final strings = S.of(context);
final sections = _resolver.resolveSections(dashboardViewModel);
return Container(
color: Theme.of(context).colorScheme.surface,
child: Column(children: [
ModalTopBar(
title: "Settings",
leadingIcon: Icon(Icons.close),
onLeadingPressed: Navigator.of(context, rootNavigator: true).pop,
onTrailingPressed: () {},
),
Expanded(
child: ListView(controller: ModalScrollController.of(context), children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Column(
spacing: 16,
children: SettingsSectionData.all.expand((section) {
return [
if (section.titleIconPath.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
spacing: 8,
children: [
HashImageWidget(
imageUrl: section.titleIconPath,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant,
BlendMode.srcIn,
)),
Text(section.title, style: Theme.of(context).textTheme.titleMedium),
],
),
),
NewListSections(sections: {section.title: buildItems(section)}),
];
}).toList(),
),
child: Column(
children: [
ModalTopBar(
title: _resolver.titleFor(dashboardViewModel.wallet.type, strings),
titleLeadingWidget: HashImageWidget(
imageUrl: _resolver.iconPathFor(dashboardViewModel.wallet.type),
width: 24,
height: 24,
),
]),
),
]),
leadingIcon: const Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: strings.seed_alert_back,
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
child: ListView(
controller: ModalScrollController.of(context),
padding: const EdgeInsets.fromLTRB(18, 0, 18, 32),
children: [
for (var index = 0; index < sections.length; index++) ...[
if (index > 0) const SizedBox(height: 24),
NewListSections(
sections: {
'wallet_settings_$index': sections[index]
.map(
(item) => ListItemRegularRow(
keyValue: item.route,
label: item.title,
iconPath: item.iconPath,
onTap: () => _openItem(context, item),
),
)
.toList(),
},
),
],
],
),
),
],
),
);
}
Future<void> _openItem(BuildContext context, SettingsListItem item) async {
final arguments = item.routeArgsBuilder?.call(dashboardViewModel) ?? item.routeArgs;
if (item.requireAuth) {
await authService.authenticateAction(
context,
conditionToDetermineIfToUse2FA: item.use2fa(dashboardViewModel),
route: item.route,
arguments: arguments,
);
return;
}
await Navigator.of(context).pushNamed(item.route, arguments: arguments);
}
}
class _SettingsSectionHeader extends StatelessWidget {
const _SettingsSectionHeader({
required this.iconPath,
required this.title,
this.foregroundColor,
});
final String iconPath;
final String title;
final Color? foregroundColor;
@override
Widget build(BuildContext context) {
final color = foregroundColor ?? Theme.of(context).colorScheme.onSurfaceVariant;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
HashImageWidget(
imageUrl: iconPath,
height: 20,
width: 20,
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: color),
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
+544
View File
@@ -0,0 +1,544 @@
import "dart:async";
import "dart:math";
import "dart:ui";
import "package:hash_wallet/core/utilities.dart";
import "package:hash_wallet/core/amount_parsing_proxy.dart";
import "package:hash_wallet/entities/bitcoin_amount_display_mode.dart";
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item_toggle.dart";
import "package:hash_wallet/src/widgets/new_list_row/new_list_section.dart";
import "package:hash_wallet/src/screens/settings/widgets/account_creation_modal.dart";
import "package:cw_core/wallet_type.dart";
import "package:mobx/mobx.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/pages/account_education_page.dart";
import "package:hash_wallet/new-ui/pages/card_customizer.dart";
import "package:hash_wallet/new-ui/pages/hidden_accounts.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/cards/balance_card.dart";
import "package:hash_wallet/new-ui/widgets/modern_button.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/src/widgets/alert_with_one_action.dart";
import "package:hash_wallet/view_model/dashboard/dashboard_view_model.dart";
import "package:hash_wallet/view_model/wallet_account_list/account_list_item.dart";
import "package:hash_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart";
import "package:hash_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart";
import "package:cw_core/balance_card_layout.dart";
import "package:cw_core/balance_card_style_settings.dart";
import "package:cw_core/card_design.dart";
import "package:cw_core/sync_status.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
class AccountCustomizerListItem {
const AccountCustomizerListItem({
required this.card,
required this.accountListItem,
required this.settings,
});
final BalanceCard card;
final AccountListItem accountListItem;
final BalanceCardStyleSettings? settings;
}
class WalletAccountsPage extends StatefulWidget {
const WalletAccountsPage({
required this.accountListViewModel,
required this.accountEditOrCreateViewModel,
required this.dashboardViewModel,
super.key,
});
final WalletAccountListViewModel accountListViewModel;
final WalletAccountEditOrCreateViewModel accountEditOrCreateViewModel;
final DashboardViewModel dashboardViewModel;
@override
State<WalletAccountsPage> createState() => _WalletAccountsPageState();
}
class _WalletAccountsPageState extends State<WalletAccountsPage> {
final List<AccountCustomizerListItem> _items = [];
bool _hasArchivedAccounts = false;
bool _loading = true;
ReactionDisposer? _accountsReaction;
bool get _isMultiAccountsEnabled => widget.dashboardViewModel.isMultiAccountsEnabled;
String get _assetName => AmountParsingProxy(
widget.dashboardViewModel.settingsStore.displayAmountsInSatoshi,
).getCryptoSymbol(widget.accountListViewModel.currency);
bool get _capitalizeAssetName =>
widget.dashboardViewModel.wallet.type != WalletType.bitcoin ||
widget.dashboardViewModel.settingsStore.displayAmountsInSatoshi !=
BitcoinAmountDisplayMode.satoshi;
double get cardWidth => min(MediaQuery.sizeOf(context).width * 0.9, 768);
@override
void initState() {
super.initState();
_accountsReaction = reaction(
(_) => widget.accountListViewModel.accounts
.map((account) => "${account.id}:${account.label}:${account.balance}")
.join(","),
(_) {
if (mounted) unawaited(loadCards());
},
);
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_initialize()));
}
Future<void> _initialize() async {
await widget.dashboardViewModel.loadCardDesigns();
if (!mounted) return;
await _bringActiveAccountToFront();
if (!mounted || !_isMultiAccountsEnabled || _items.isEmpty) {
return;
}
final educationPage = AccountEducationPage(
settingsStore: widget.dashboardViewModel.settingsStore,
);
if (!educationPage.isDismissed) {
await educationPage.show(context);
}
}
@override
void dispose() {
_accountsReaction?.call();
saveCardOrder().then((value) => widget.dashboardViewModel.loadCardDesigns());
super.dispose();
}
int get _walletInfoId => widget.dashboardViewModel.wallet.walletInfo.internalId;
Future<void> _bringActiveAccountToFront() async {
await loadCards();
final activeAccount =
widget.accountListViewModel.accounts.firstWhereOrNull((account) => account.isSelected);
if (activeAccount == null || _items.isEmpty) {
return;
}
final index = _items.indexWhere((item) => item.accountListItem.id == activeAccount.id);
if (index == -1 || index == _items.length - 1 || !mounted) {
return;
}
reorder(index, _items.length);
await saveCardOrder();
await widget.dashboardViewModel.loadCardDesigns();
}
Future<void> loadCards() async {
if (!mounted) return;
final accounts = widget.accountListViewModel.accounts;
final unnamedAccount = S.of(context).unnamed_account;
final resolvedCardWidth = cardWidth;
final walletCurrency = widget.dashboardViewModel.wallet.currency;
final styleSettings = await BalanceCardStyleSettings.getAll(_walletInfoId);
final layout = BalanceCardLayout.resolve(
accountIndices: accounts.map((account) => account.id).toList(),
settings: styleSettings,
);
final List<AccountCustomizerListItem> newItems = [];
for (int position = 0; position < layout.visible.length; position++) {
final accountIndex = layout.visible[position];
final account = accounts.firstWhereOrNull((item) => item.id == accountIndex);
if (account == null) {
continue;
}
final setting = layout.settingFor(accountIndex);
final isFrontCard = position == layout.visible.length - 1;
final accountLabel = account.label.trim().isEmpty ? unnamedAccount : account.label;
newItems.add(
AccountCustomizerListItem(
card: BalanceCard(
accountName: "${account.id + 1}. $accountLabel",
accountBalance: account.balance,
fiatBalance:
widget.dashboardViewModel.balanceViewModel.accountFiatBalance(account) ?? "",
designSwitchDuration: Duration.zero,
assetName: _assetName,
capitalizeAssetName: _capitalizeAssetName,
onCustomizeTapped: isFrontCard ? _openCardCustomizer : null,
selected: isFrontCard,
width: resolvedCardWidth,
design: CardDesign.fromStyleSettings(setting, walletCurrency),
),
accountListItem: account,
settings: setting,
),
);
}
if (mounted) {
setState(() {
_items
..clear()
..addAll(newItems);
_hasArchivedAccounts = layout.hidden.isNotEmpty;
_loading = false;
});
}
if (layout.needsRepair) {
await BalanceCardStyleSettings.setVisibleOrder(_walletInfoId, layout.orders);
}
}
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
children: [
ModalTopBar(
title: S.of(context).accounts,
leadingIcon: const Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: Navigator.of(context).maybePop,
trailingWidget: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
ModernButton(
semanticLabel: S.of(context).accounts_help,
icon: const Icon(Icons.question_mark),
size: 36,
iconSize: 19,
onPressed: () => AccountEducationPage(
settingsStore: widget.dashboardViewModel.settingsStore,
).show(context),
),
if (_isMultiAccountsEnabled)
ModernButton.svg(
semanticLabel: S.of(context).archived_accounts,
svgPath: "assets/new-ui/archived.svg",
size: 36,
iconSize: 19,
backgroundColor:
_hasArchivedAccounts ? Theme.of(context).colorScheme.primary : null,
iconColor:
_hasArchivedAccounts ? Theme.of(context).colorScheme.onPrimary : null,
onPressed: _openArchivedAccounts,
),
],
),
),
if (widget.dashboardViewModel.canToggleMultiAccounts)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: NewListSections(sections: {
"": [
ListItemToggle(
keyValue: S.of(context).multiple_accounts,
label: S.of(context).multiple_accounts,
value: _isMultiAccountsEnabled,
onChanged: (value) async {
await widget.dashboardViewModel.setMultiAccountsEnabled(value);
if (mounted) await _initialize();
},
),
],
}),
),
if (_isMultiAccountsEnabled)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 18),
child: Text(
S.of(context).account_customizer_desc,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
Expanded(
child: !_isMultiAccountsEnabled
? const SizedBox.shrink()
: _loading
? const Center(child: CupertinoActivityIndicator())
: _items.isEmpty
? const SizedBox.shrink()
: _AccountCards(
items: _items,
cardWidth: cardWidth,
onReorder: reorder,
onAddAccount: _showAddAccountModal,
),
),
],
),
);
bool _checkReadyToManage() {
if (widget.dashboardViewModel.status is! SyncedSyncStatus) {
showDialog(
context: context,
builder: (context) => AlertWithOneAction(
alertTitle: S.of(context).wallet_is_syncing,
alertContent: S.of(context).cannot_manage_accounts_during_sync,
buttonText: S.of(context).ok,
buttonAction: Navigator.of(context).pop,
),
);
return false;
}
return true;
}
Future<void> _showAddAccountModal() async {
if (!_checkReadyToManage()) {
return;
}
final modal = AccountCreationModal(viewModel: widget.accountEditOrCreateViewModel);
final res = await showCupertinoModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
expand: true,
enableDrag: false,
builder: (_) => Material(child: modal),
);
if (res == true && mounted) {
await widget.accountListViewModel.reload();
final accounts = widget.accountListViewModel.accounts;
if (accounts.isNotEmpty) {
await widget.accountListViewModel.select(
accounts.reduce((a, b) => a.id > b.id ? a : b),
);
}
await widget.dashboardViewModel.loadCardDesigns();
await loadCards();
await saveCardOrder();
}
}
Future<void> _openArchivedAccounts() async {
await Navigator.of(context).push<void>(
CupertinoPageRoute(
builder: (context) => Material(
child: HiddenAccountsPage(
accountListViewModel: widget.accountListViewModel,
dashboardViewModel: widget.dashboardViewModel,
),
),
),
);
if (!mounted) {
return;
}
await widget.dashboardViewModel.loadCardDesigns();
await loadCards();
}
Future<void> _openCardCustomizer() async {
if (!_checkReadyToManage()) {
return;
}
final account = _items.last.accountListItem;
await widget.accountListViewModel.select(account);
if (!mounted) return;
final archived = await CardCustomizer.show(
context: context,
dashboardViewModel: widget.dashboardViewModel,
account: account,
canHide: _items.length > 1,
asPage: true,
);
if (archived && _items.length > 1) {
final nextAccount = _items[_items.length - 2].accountListItem;
await widget.accountListViewModel.select(
widget.accountListViewModel.accounts
.firstWhereOrNull((item) => item.id == nextAccount.id) ??
nextAccount,
);
}
await widget.accountListViewModel.reload();
await widget.dashboardViewModel.loadCardDesigns();
if (!mounted) {
return;
}
await loadCards();
}
void reorder(int oldIndex, int newIndex) {
setState(() {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final AccountCustomizerListItem item = _items.removeAt(oldIndex);
_items.insert(newIndex, item);
});
// necessary to copy all this to keep constant constructor for BalanceCard
for (int i = 0; i < _items.length; i++) {
_items[i] = AccountCustomizerListItem(
card: BalanceCard(
accountName: _items[i].card.accountName,
balance: _items[i].card.balance,
accountIndex: _items[i].card.accountIndex,
accountBalance: _items[i].card.accountBalance,
fiatBalance: _items[i].card.fiatBalance,
assetName: _items[i].card.assetName,
capitalizeAssetName: _items[i].card.capitalizeAssetName,
designSwitchDuration: _items[i].card.designSwitchDuration,
onCustomizeTapped: (i == _items.length - 1) ? _openCardCustomizer : null,
selected: i == _items.length - 1,
width: _items[i].card.width,
design: _items[i].card.design,
),
accountListItem: _items[i].accountListItem,
settings: _items[i].settings,
);
}
if (newIndex == _items.length - 1 || oldIndex == _items.length - 1) {
widget.accountListViewModel.select(_items[_items.length - 1].accountListItem);
}
}
Future<void> saveCardOrder() async {
if (!_isMultiAccountsEnabled) return;
await BalanceCardStyleSettings.setOrder(_walletInfoId, {
for (int position = 0; position < _items.length; position++)
_items[position].accountListItem.id: position,
});
}
}
class _AccountCards extends StatelessWidget {
const _AccountCards({
required this.items,
required this.cardWidth,
required this.onReorder,
required this.onAddAccount,
});
static const double _kStackVisibleFactor = 0.2;
final List<AccountCustomizerListItem> items;
final double cardWidth;
final ReorderCallback onReorder;
final VoidCallback onAddAccount;
@override
Widget build(BuildContext context) => Stack(
children: [
ReorderableListView.builder(
padding: const EdgeInsets.only(bottom: 196),
scrollController: ModalScrollController.of(context),
onReorder: onReorder,
proxyDecorator: (child, index, animation) => AnimatedBuilder(
animation: animation,
builder: (context, _) {
final animValue = Curves.easeOutCubic.transform(animation.value);
final scale = lerpDouble(1, 1.05, animValue)!;
return Opacity(
opacity: 1 - animValue.clamp(0.0, 0.1),
child: Center(
child: SizedBox(
width: cardWidth,
child: Transform.scale(
scale: scale,
child: child,
),
),
),
);
},
child: items[index].card,
),
itemCount: items.length,
itemBuilder: (context, index) {
final selectedItemIndex = items.length - 1;
return Container(
key: ValueKey(items[index].accountListItem.id),
child: Semantics(
button: true,
selected: selectedItemIndex == index,
label: "${items[index].accountListItem.id + 1}. "
"${items[index].accountListItem.label.trim().isEmpty ? S.of(context).unnamed_account : items[index].accountListItem.label}",
onTap: () => onReorder(index, items.length),
child: GestureDetector(
excludeFromSemantics: true,
onTap: () {
onReorder(index, items.length);
},
child: Align(
alignment: Alignment.topCenter,
heightFactor: _kStackVisibleFactor,
child: items[index].card,
),
),
),
);
},
),
SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 50),
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Material(
color: Colors.transparent,
child: MergeSemantics(
child: Semantics(
button: true,
child: InkWell(
borderRadius: BorderRadius.circular(999999),
onTap: onAddAccount,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(999999),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8,
children: [
Icon(
Icons.add,
size: 28,
color: Theme.of(context).colorScheme.primary,
),
Text(
S.of(context).add_account,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
),
),
),
),
),
),
),
],
);
}
@@ -0,0 +1,29 @@
import "package:hash_wallet/core/wallet_loading_service.dart";
import "package:hash_wallet/store/app_store.dart";
import "package:cw_core/exceptions.dart";
import "package:cw_core/wallet_info.dart";
class WalletSwitchService {
WalletSwitchService({
required WalletLoadingService walletLoadingService,
required AppStore appStore,
}) : _walletLoadingService = walletLoadingService,
_appStore = appStore;
final AppStore _appStore;
final WalletLoadingService _walletLoadingService;
Future<void> switchToWallet(WalletInfo walletInfo) async {
final wallet = await _walletLoadingService.load(walletInfo.type, walletInfo.name);
// load() recovers from a corrupted wallet by opening any other wallet it can,
// so the one it returns may not be the one that was requested.
if (wallet.name != walletInfo.name || wallet.type != walletInfo.type) {
throw WalletSwitchException(
"wallet switch loaded ${wallet.name} instead of ${walletInfo.name}",
);
}
await _appStore.changeCurrentWallet(wallet);
}
}
@@ -1,34 +1,51 @@
import 'package:bloc/bloc.dart';
import 'package:hash_wallet/monero/monero.dart';
import 'package:hash_wallet/wownero/wownero.dart';
import "package:bloc/bloc.dart";
import "package:hash_wallet/entities/bitcoin_amount_display_mode.dart";
import "package:hash_wallet/bitcoin/bitcoin.dart";
import "package:hash_wallet/monero/monero.dart";
import "package:hash_wallet/wownero/wownero.dart" as wow;
import "package:cw_core/balance_card_style_settings.dart";
import 'package:cw_core/card_design.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/wallet_base.dart';
import "package:cw_core/card_design.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/wallet_base.dart";
import "package:cw_core/wallet_type.dart";
import 'package:flutter/src/painting/gradient.dart';
import 'package:meta/meta.dart';
import "package:flutter/src/painting/gradient.dart";
import "package:meta/meta.dart";
part 'card_customizer_event.dart';
part 'card_customizer_state.dart';
part "card_customizer_event.dart";
part "card_customizer_state.dart";
class CardCustomizerBlocParams {
CardCustomizerBlocParams({required this.lightningMode, required this.canHide, this.amountDisplayMode});
final bool lightningMode;
final BitcoinAmountDisplayMode? amountDisplayMode;
final bool canHide;
}
class CardCustomizerBloc extends Bloc<CardCustomizerEvent, CardCustomizerState> {
final WalletBase _wallet;
final bool lightningMode;
final bool displaySats;
CardCustomizerBloc(this._wallet, {this.lightningMode = false, this.displaySats = false})
CardCustomizerBloc(this._wallet, {this.lightningMode = false, this.displaySats = false, this.canHide = false})
: super(CardCustomizerNotLoaded(
0, 0, [CardDesign.genericDefault], [], "", -1, displaySats, 0)) {
0, 0, [CardDesign.genericDefault], [], "", -1, displaySats, 0,),) {
on<_Init>(_init);
on<CardDesignSelected>(_onDesignSelected);
on<ColorSelected>(_onColorSelected);
on<IconStyleSelected>(_onIconStyleSelected);
on<DesignSaved>(_onDesignSaved);
on<AccountHidden>(_onAccountHidden);
on<AccountNameChanged>(_onAccountNameChanged);
add(_Init());
}
final WalletBase _wallet;
final bool lightningMode;
final bool displaySats;
final bool canHide;
bool get _accountNameEnabled => !lightningMode &&
(_wallet.hasNativeAccounts || _wallet.walletInfo.isMultiAccountsEnabled == true);
List<Gradient> _updateAvailableColors(CardDesign currentDesign) {
final list = List<Gradient>.from(CardDesign.allGradients, growable: true);
if (CardDesign.specialDesignsForCurrencies[_wallet.currency] != null) {
@@ -37,139 +54,245 @@ class CardCustomizerBloc extends Bloc<CardCustomizerEvent, CardCustomizerState>
return list;
}
Future<BalanceCardStyleSettings?> _loadCurrentDesignSettings(int accountIndex) async {
return (await BalanceCardStyleSettings.get(_wallet.walletInfo.internalId, accountIndex));
Future<BalanceCardStyleSettings?> _loadCurrentDesignSettings(int accountIndex) =>
BalanceCardStyleSettings.get(_wallet.walletInfo.internalId, accountIndex);
Future<int> _cardOrderFor(BalanceCardStyleSettings? currentDesignSettings) async {
if (currentDesignSettings != null) {
return currentDesignSettings.cardOrder;
}
var highest = -1;
for (final setting in await BalanceCardStyleSettings.getAll(_wallet.walletInfo.internalId)) {
if (setting.cardOrder > highest) {
highest = setting.cardOrder;
}
}
return highest + 1;
}
List<CardDesign> _initAvailableDesigns({bool lightningMode = false}) {
final List<CardDesign> ret = List<CardDesign>.empty(growable: true);
final curr = lightningMode ? CryptoCurrency.btcln : _wallet.currency;
ret.add(CardDesign.gradientOnlyDesign);
ret.add(CardDesign.forCurrencyIcon(curr));
if (CardDesign.specialDesignsForCurrencies[curr] != null)
if (CardDesign.specialDesignsForCurrencies[curr] != null) {
ret.add(CardDesign.forCurrencySpecial(curr));
}
return ret;
}
int _initSelectedDesign(CardDesign currentDesign) {
if (currentDesign.backgroundType == CardDesignBackgroundTypes.svgIcon)
if (currentDesign.backgroundType == CardDesignBackgroundTypes.gradientOnly) {
return 0;
else if (currentDesign.backgroundType == CardDesignBackgroundTypes.svgFull)
}
if (currentDesign.backgroundType == CardDesignBackgroundTypes.svgIcon) {
return 1;
else
}
if (currentDesign.backgroundType == CardDesignBackgroundTypes.svgFull) {
return 2;
}
return 0;
}
int _initSelectedIconIndex(
BalanceCardStyleSettings? settings,
List<CardIconPath> availableIconPaths,
) {
if (settings == null || availableIconPaths.isEmpty) {
return 0;
}
if (settings.iconStyleIndex >= availableIconPaths.length) {
return 0;
}
return settings.iconStyleIndex;
}
int _initSelectedColor(CardDesign currentDesign) {
int ret = CardDesign.allGradients.indexOf(currentDesign.gradient);
if (ret == -1) {
// special design with its own color. select last color in list.
return CardDesign.allGradients.length;
} else if (ret == -1) {
// no color selected, select default.
return 0;
} else {
// select whatever's selected.
return ret;
}
final ret = CardDesign.allGradients.indexOf(currentDesign.gradient);
return ret == -1 ? CardDesign.allGradients.length : ret;
}
void _init(_Init event, Emitter<CardCustomizerState> emit) async {
late final account;
if (_wallet.type == WalletType.monero) {
account = monero!.getCurrentAccount(_wallet);
} else if (_wallet.type == WalletType.wownero) {
account = wownero!.getCurrentAccount(_wallet);
} else {
account = null;
}
final accountName = (account?.label ?? "") as String;
late final int accountIndex;
if (account != null) {
accountIndex = account.id as int;
} else if (lightningMode) {
accountIndex = 0;
} else {
accountIndex = -1;
}
Future<void> _init(_Init event, Emitter<CardCustomizerState> emit) async {
final accountInfo = await _getCurrentAccountInfo();
final accountName = accountInfo.accountName;
final accountIndex = accountInfo.accountIndex;
final curr = lightningMode ? CryptoCurrency.btcln : _wallet.currency;
final currentDesignSettings = await _loadCurrentDesignSettings(accountIndex);
final currentDesign = CardDesign.fromStyleSettings(
currentDesignSettings, lightningMode ? CryptoCurrency.btcln : _wallet.currency);
final currentDesign = CardDesign.fromStyleSettings(currentDesignSettings, curr);
final availableDesigns = _initAvailableDesigns(lightningMode: lightningMode);
final availableColors = _updateAvailableColors(currentDesign);
final selectedDesign = _initSelectedDesign(currentDesign);
final selectedDesignIndex = _initSelectedDesign(currentDesign);
final selectedColor = _initSelectedColor(currentDesign);
final availableIconPaths = CardDesign.iconPathsForWalletType(curr);
final selectedIconIndex = _initSelectedIconIndex(currentDesignSettings, availableIconPaths);
emit(CardCustomizerInitial(selectedDesign, selectedColor, availableDesigns, availableColors,
accountName, accountIndex, displaySats, currentDesignSettings?.cardOrder ?? 0));
emit(CardCustomizerInitial(
selectedDesignIndex,
selectedColor,
availableDesigns,
availableColors,
accountName,
accountIndex,
displaySats,
await _cardOrderFor(currentDesignSettings),
availableIconPaths: availableIconPaths,
selectedIconIndex: selectedIconIndex,),);
}
Future<({String accountName, int accountIndex})> _getCurrentAccountInfo() async {
if (lightningMode) {
return (accountName: "", accountIndex: -2);
}
final account = await _resolveCurrentAccount();
if (!_accountNameEnabled) {
return (accountName: "", accountIndex: account.accountIndex);
}
return account;
}
Future<({String accountName, int accountIndex})> _resolveCurrentAccount() async {
if (_wallet.type == WalletType.monero) {
final account = monero!.getCurrentAccount(_wallet);
return (accountName: account.label, accountIndex: account.id);
}
if (_wallet.type == WalletType.wownero) {
final account = wow.wownero!.getCurrentAccount(_wallet);
return (accountName: account.label, accountIndex: account.id);
}
if (_wallet.type == WalletType.bitcoin) {
final account = await bitcoin!.getCurrentAccount(_wallet);
return (accountName: account.label, accountIndex: account.id);
}
return (accountName: "", accountIndex: -1);
}
void _onDesignSelected(CardDesignSelected event, Emitter<CardCustomizerState> emit) {
final newColors = _updateAvailableColors(state.availableDesigns[event.newDesignIndex]);
late final int newColorIndex;
if (newColors.length < state.availableColors.length) {
if (newColors.isEmpty) {
newColorIndex = 0;
} else if (newColors.length < state.availableColors.length) {
newColorIndex = 0;
} else {
newColorIndex = state.selectedColorIndex;
newColorIndex = state.selectedColorIndex.clamp(0, newColors.length - 1);
}
emit(state.copyWith(
selectedDesignIndex: event.newDesignIndex,
availableColors: newColors,
selectedColorIndex: newColorIndex));
selectedColorIndex: newColorIndex,),);
}
void _onColorSelected(ColorSelected event, Emitter<CardCustomizerState> emit) {
emit(state.copyWith(selectedColorIndex: event.newColorIndex));
}
void _onIconStyleSelected(IconStyleSelected event, Emitter<CardCustomizerState> emit) {
emit(state.copyWith(selectedIconIndex: event.iconIndex));
}
void _onAccountNameChanged(AccountNameChanged event, Emitter<CardCustomizerState> emit) {
emit(state.copyWith(accountName: event.newAccountName));
}
void _onDesignSaved(DesignSaved event, Emitter<CardCustomizerState> emit) {
BalanceCardStyleSettings.fromCardDesign(_wallet.walletInfo.internalId, state.accountIndex,
state.cardOrder, state.selectedDesign)
.insert()
.then((value) {
emit(CardCustomizerSaved(
state.selectedDesignIndex,
state.selectedColorIndex,
state.availableDesigns,
state.availableColors,
state.accountName,
state.accountIndex,
state.displaySats,
state.cardOrder));
});
saveAccountName();
Future<void> _onDesignSaved(DesignSaved event, Emitter<CardCustomizerState> emit) async {
final currentSettings = await _loadCurrentDesignSettings(state.accountIndex);
await BalanceCardStyleSettings.fromCardDesign(
walletInfoId: _wallet.walletInfo.internalId,
accountIndex: state.accountIndex,
cardOrder: state.cardOrder,
design: state.selectedDesign,
iconStyleIndex: state.selectedIconIndex,
gradientIndexOverride: state.selectedColorIndex,
hidden: currentSettings?.hidden ?? false,
).insert();
await saveAccountName();
emit(
CardCustomizerSaved(
state.selectedDesignIndex,
state.selectedColorIndex,
state.availableDesigns,
state.availableColors,
state.accountName,
state.accountIndex,
state.displaySats,
state.cardOrder,
availableIconPaths: state.availableIconPaths,
selectedIconIndex: state.selectedIconIndex,
),
);
}
Future<void> _onAccountHidden(AccountHidden event, Emitter<CardCustomizerState> emit) async {
await BalanceCardStyleSettings.fromCardDesign(
walletInfoId: _wallet.walletInfo.internalId,
accountIndex: state.accountIndex,
cardOrder: state.cardOrder,
design: state.selectedDesign,
iconStyleIndex: state.selectedIconIndex,
gradientIndexOverride: state.selectedColorIndex,
hidden: true,
).insert();
emit(
CardCustomizerSaved(
state.selectedDesignIndex,
state.selectedColorIndex,
state.availableDesigns,
state.availableColors,
state.accountName,
state.accountIndex,
state.displaySats,
state.cardOrder,
),
);
}
Future<void> saveAccountName() async {
if (!_accountNameEnabled) return;
if (_wallet.type == WalletType.monero) {
await saveMoneroAccountName();
}
if (_wallet.type == WalletType.wownero) {
} else if (_wallet.type == WalletType.wownero) {
await saveWowneroAccountName();
} else if (_wallet.type == WalletType.bitcoin) {
await saveBitcoinAccountName();
}
}
Future<void> saveMoneroAccountName() async {
final MoneroAccountList moneroAccountList = monero!.getAccountList(_wallet);
await moneroAccountList.setLabelAccount(_wallet,
accountIndex: state.accountIndex, label: state.accountName);
accountIndex: state.accountIndex, label: state.accountName,);
await _wallet.save();
}
Future<void> saveWowneroAccountName() async {
final WowneroAccountList wowneroAccountList = wownero!.getAccountList(_wallet);
await wowneroAccountList.setLabelAccount(_wallet,
accountIndex: state.accountIndex, label: state.accountName);
final accountList = wow.wownero!.getAccountList(_wallet);
await accountList.setLabelAccount(
_wallet,
accountIndex: state.accountIndex,
label: state.accountName,
);
await _wallet.save();
}
Future<void> saveBitcoinAccountName() async {
await _wallet.walletInfo.renameAccount(
accountIndex: state.accountIndex,
label: state.accountName,
);
}
}
@@ -1,4 +1,4 @@
part of 'card_customizer_bloc.dart';
part of "card_customizer_bloc.dart";
@immutable
sealed class CardCustomizerEvent {}
@@ -6,21 +6,29 @@ sealed class CardCustomizerEvent {}
class _Init extends CardCustomizerEvent {}
class CardDesignSelected extends CardCustomizerEvent {
final int newDesignIndex;
CardDesignSelected(this.newDesignIndex);
final int newDesignIndex;
}
class ColorSelected extends CardCustomizerEvent {
final int newColorIndex;
ColorSelected(this.newColorIndex);
final int newColorIndex;
}
class AccountNameChanged extends CardCustomizerEvent {
final String newAccountName;
AccountNameChanged(this.newAccountName);
final String newAccountName;
}
class DesignSaved extends CardCustomizerEvent {}
class IconStyleSelected extends CardCustomizerEvent {
IconStyleSelected(this.iconIndex);
final int iconIndex;
}
class AccountHidden extends CardCustomizerEvent {}
@@ -9,12 +9,40 @@ sealed class CardCustomizerState {
final bool displaySats;
final List<CardDesign> availableDesigns;
final List<Gradient> availableColors;
final List<CardIconPath> availableIconPaths;
final int selectedIconIndex;
CardCustomizerState(this.selectedDesignIndex, this.selectedColorIndex, this.availableDesigns,
this.availableColors, this.accountName, this.accountIndex, this.displaySats, this.cardOrder);
CardCustomizerState(
this.selectedDesignIndex,
this.selectedColorIndex,
this.availableDesigns,
this.availableColors,
this.accountName,
this.accountIndex,
this.displaySats,
this.cardOrder, {
this.availableIconPaths = const [],
this.selectedIconIndex = 0,
});
CardDesign get selectedDesign {
return availableDesigns[selectedDesignIndex].withGradient(selectedColor);
final gradient = selectedColor;
final baseDesign = availableDesigns[selectedDesignIndex];
CardDesign design;
if (CardDesign.preferredColorCombinations.containsKey(gradient)) {
design = baseDesign.withGradient(gradient);
} else {
final specialDesign = availableDesigns.length > 2 ? availableDesigns.last : null;
final textColors = specialDesign?.colors ?? baseDesign.colors;
design = baseDesign.withGradientAndColorCombination(gradient, textColors);
}
if (design.backgroundType == CardDesignBackgroundTypes.svgIcon &&
availableIconPaths.isNotEmpty) {
design = design.withIcon(availableIconPaths[selectedIconIndex]);
}
return design;
}
CardCustomizerState copyWith({
@@ -25,6 +53,8 @@ sealed class CardCustomizerState {
String? accountName,
int? accountIndex,
int? cardOrder,
List<CardIconPath>? availableIconPaths,
int? selectedIconIndex,
});
Gradient get selectedColor => availableColors[selectedColorIndex];
@@ -49,7 +79,9 @@ final class CardCustomizerNotLoaded extends CardCustomizerState {
List<Gradient>? availableColors,
String? accountName,
int? accountIndex,
int? cardOrder}) {
int? cardOrder,
List<CardIconPath>? availableIconPaths,
int? selectedIconIndex}) {
// this is never gonna be copied. it's near-instantly replaced with initial
throw UnimplementedError();
}
@@ -64,9 +96,12 @@ final class CardCustomizerInitial extends CardCustomizerState {
String accountName,
int accountIndex,
bool displaySats,
int cardOrder,
) : super(selectedDesignIndex, selectedColorIndex, availableDesigns, availableColors, accountName,
accountIndex, displaySats, cardOrder);
int cardOrder, {
List<CardIconPath> availableIconPaths = const [],
int selectedIconIndex = 0,
}) : super(selectedDesignIndex, selectedColorIndex, availableDesigns, availableColors,
accountName, accountIndex, displaySats, cardOrder,
availableIconPaths: availableIconPaths, selectedIconIndex: selectedIconIndex);
CardCustomizerInitial copyWith({
int? selectedDesignIndex,
@@ -77,6 +112,8 @@ final class CardCustomizerInitial extends CardCustomizerState {
int? accountIndex,
bool? displaySats,
int? cardOrder,
List<CardIconPath>? availableIconPaths,
int? selectedIconIndex,
}) {
return CardCustomizerInitial(
selectedDesignIndex ?? this.selectedDesignIndex,
@@ -87,6 +124,8 @@ final class CardCustomizerInitial extends CardCustomizerState {
accountIndex ?? this.accountIndex,
displaySats ?? this.displaySats,
cardOrder ?? this.cardOrder,
availableIconPaths: availableIconPaths ?? this.availableIconPaths,
selectedIconIndex: selectedIconIndex ?? this.selectedIconIndex,
);
}
}
@@ -100,7 +139,9 @@ final class CardCustomizerSaved extends CardCustomizerState {
super.accountName,
super.accountIndex,
super.displaySats,
super.cardOrder);
super.cardOrder,
{super.availableIconPaths,
super.selectedIconIndex});
@override
CardCustomizerState copyWith(
@@ -111,7 +152,9 @@ final class CardCustomizerSaved extends CardCustomizerState {
String? accountName,
int? accountIndex,
bool? displaySats,
int? cardOrder}) {
int? cardOrder,
List<CardIconPath>? availableIconPaths,
int? selectedIconIndex}) {
return CardCustomizerSaved(
selectedDesignIndex ?? this.selectedDesignIndex,
selectedColorIndex ?? this.selectedColorIndex,
@@ -121,6 +164,8 @@ final class CardCustomizerSaved extends CardCustomizerState {
accountIndex ?? this.accountIndex,
displaySats ?? this.displaySats,
cardOrder ?? this.cardOrder,
availableIconPaths: availableIconPaths ?? this.availableIconPaths,
selectedIconIndex: selectedIconIndex ?? this.selectedIconIndex,
);
}
}
@@ -0,0 +1,179 @@
import "dart:async";
import "package:bloc/bloc.dart";
import "package:bloc_concurrency/bloc_concurrency.dart";
import "package:hash_wallet/core/utilities.dart";
import "package:hash_wallet/entities/fiat_api_mode.dart";
import "package:hash_wallet/entities/fiat_currency.dart";
import "package:hash_wallet/new-ui/model/charts/charts_asset.dart";
import "package:hash_wallet/new-ui/model/charts/price_data.dart";
import "package:hash_wallet/new-ui/model/charts/price_store.dart";
import "package:hash_wallet/new-ui/model/charts/util/chart_range.dart";
import "package:hash_wallet/new-ui/model/charts/util/price_change_data.dart";
import "package:hash_wallet/new-ui/model/charts/util/price_change_direction.dart";
import "package:hash_wallet/new-ui/model/charts/util/price_data_sort_criteria.dart";
import "package:hash_wallet/store/app_store.dart";
import "package:cw_core/amount/money.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, transformer: sequential());
}
final PriceStore priceStore;
final AppStore appStore;
Future<void> _init(Init event, Emitter<ChartsState> emit) async {
if(appStore.settingsStore.fiatApiMode == FiatApiMode.disabled) {
emit(const ChartsDisabled());
return;
}
if (state is! ChartsInitial) {
emit(const ChartsInitial());
}
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.thirtyDays,
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,136 @@
part of "charts_bloc.dart";
@immutable
sealed class ChartsState {
const ChartsState();
}
final class ChartsInitial extends ChartsState {
const ChartsInitial();
}
final class ChartsDisabled extends ChartsState {
const ChartsDisabled();
}
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,
);
}
@@ -1,7 +1,7 @@
import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:hash_wallet/bitcoin/bitcoin.dart';
import 'package:hash_wallet/entities/lnurlpay_record.dart';
import 'package:hash_wallet/core/address_resolver/lnurl_pay/lnurlpay_record.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/wallet_base.dart';
@@ -0,0 +1,174 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/money/money_text.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/themes/core/theme_extension.dart";
import "package:hash_wallet/view_model/wallet_account_list/account_list_item.dart";
import "package:flutter/material.dart";
class ArchiveConfirmationContent extends StatelessWidget {
const ArchiveConfirmationContent({
required this.account,
required this.fiatBalance,
required this.isFunded,
});
final AccountListItem account;
final String? fiatBalance;
final bool isFunded;
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isFunded) ...[
Text(
S.of(context).archive_account_funds_title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.customColors.warningOutlineColor,
),
),
const SizedBox(height: 20),
AccountFundsSummary(
account: account,
fiatBalance: fiatBalance,
borderColor: context.customColors.warningOutlineColor,
),
const SizedBox(height: 24),
Text(
S.of(context).archive_account_move_funds,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.customColors.warningOutlineColor,
),
),
const SizedBox(height: 20),
Text(
S.of(context).archive_account_funded_disclaimer,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
] else ...[
AccountSummary(account: account),
const SizedBox(height: 24),
Text(
S.of(context).archive_account_empty_disclaimer,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
Text(
S.of(context).archive_account_restore_hint,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
);
}
class AccountSummary extends StatelessWidget {
const AccountSummary({required this.account});
final AccountListItem account;
@override
Widget build(BuildContext context) {
final label = account.label.trim();
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
HashImageWidget(
imageUrl: "assets/new-ui/account.svg",
width: 24,
height: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: "${account.id + 1}. ",
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
TextSpan(
text: label.isEmpty ? S.of(context).unnamed_account : label,
),
],
),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
),
),
],
),
);
}
}
class AccountFundsSummary extends StatelessWidget {
const AccountFundsSummary({
required this.account,
required this.fiatBalance,
required this.borderColor,
});
final AccountListItem account;
final String? fiatBalance;
final Color borderColor;
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
HashImageWidget(
imageUrl: account.balance.currency.iconPath ??
"assets/new-ui/crypto_full_icons/${account.balance.currency.name.toLowerCase()}.svg",
width: 24,
height: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MoneyText(
account.balance,
fractionalDigits: account.balance.decimals,
style: Theme.of(context).textTheme.bodyMedium,
),
if (fiatBalance != null) ...[
const SizedBox(height: 2),
Text(
fiatBalance!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
);
}
+391
View File
@@ -0,0 +1,391 @@
import "package:hash_wallet/core/address_resolver/address_resolver_service.dart";
import "package:hash_wallet/core/anypay/anypay_models.dart";
import "package:hash_wallet/core/anypay/anypay_service.dart";
import "package:hash_wallet/core/auth_service.dart";
import "package:hash_wallet/di.dart";
import "package:hash_wallet/evm/evm.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/pages/swap_page.dart";
import "package:hash_wallet/new-ui/widgets/anypay/evm_address_detected_sheet.dart";
import "package:hash_wallet/new-ui/widgets/anypay/network_decision_page.dart";
import "package:hash_wallet/new-ui/widgets/anypay/select_recipient_network_sheet.dart";
import "package:hash_wallet/new-ui/widgets/anypay/switch_network_wallet_page.dart";
import "package:hash_wallet/new-ui/widgets/swap_page/swap_from_send_args.dart";
import "package:hash_wallet/reactions/wallet_connect.dart";
import "package:hash_wallet/src/widgets/alert_with_one_action.dart";
import "package:hash_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart";
import "package:hash_wallet/utils/show_pop_up.dart";
import "package:hash_wallet/view_model/exchange/exchange_view_model.dart";
import "package:hash_wallet/view_model/send/send_view_model.dart";
import "package:hash_wallet/view_model/wallet_switcher_view_model.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/unspent_coin_type.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_info.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
class AnyPayFormFill {
const AnyPayFormFill({
required this.request,
this.token,
this.fallbackCurrency,
this.amountOverride,
});
final AnyPayRequest request;
final CryptoCurrency? token;
final CryptoCurrency? fallbackCurrency;
final String? amountOverride;
}
class AnyPayFlow {
AnyPayFlow({
required this.anyPayService,
required this.sendViewModel,
required this.authService,
required this.walletSwitcherViewModel,
});
final AnyPayService anyPayService;
final SendViewModel sendViewModel;
final AuthService authService;
final WalletSwitcherViewModel walletSwitcherViewModel;
bool _isSwitchingWallet = false;
bool get isSwitchingWallet => _isSwitchingWallet;
Future<AnyPayFormFill?> handleEvaluation(
BuildContext context,
AnyPayEvaluation evaluation, {
CryptoCurrency? fallbackCurrency,
}) async {
final request = evaluation.request;
if (!request.rawInput.toLowerCase().startsWith("ethereum:") &&
(request.rawInput.contains("@") || request.address.contains("@"))) {
return null;
}
try {
return await _handleDecision(context, evaluation, fallbackCurrency);
} catch (e) {
printV("Payment flow error: $e");
return AnyPayFormFill(request: request);
}
}
Future<AnyPayFormFill?> _handleDecision(
BuildContext context,
AnyPayEvaluation evaluation,
CryptoCurrency? fallbackCurrency,
) async {
final request = evaluation.request;
switch (evaluation.decision) {
case final AnyPayApplyToCurrentWallet decision:
if (request.isLightning) {
_enterLightningMode();
}
return AnyPayFormFill(
request: request,
token: decision.token,
fallbackCurrency: fallbackCurrency ?? decision.fallbackCurrency,
amountOverride: decision.amountOverride,
);
case AnyPayEvmNetworkChoice():
return _pickEvmNetwork(context, request);
case final AnyPayCrossChainPayment decision:
return _handleCrossChain(context, request, decision, fallbackCurrency);
case final AnyPayUnsupportedNetwork decision:
_showError(
context,
S.of(context).unsupported_network_requested(decision.chainId.toString()),
);
return null;
case AnyPayUnsupportedToken():
_showError(context, S.of(context).unsupported_token_requested);
return null;
case AnyPayEmptyInput():
return null;
}
}
Future<AnyPayFormFill?> _pickEvmNetwork(BuildContext context, AnyPayRequest request) async {
final chains = evm!.getAllChains();
final networks = chains.map((chain) {
final walletType = evm!.getWalletTypeByChainId(chain.chainId) ?? sendViewModel.wallet.type;
return RecipientNetworkItem(
chainId: chain.chainId,
name: chain.name,
iconPath: getCryptoCurrencyIconForWalletListItem(walletType, chainId: chain.chainId),
chainBadgeIconPath: symbolIconPathForWalletType(walletType),
);
}).toList();
final selectedChainId =
await EvmAddressDetectedSheet.show(context: context, networks: networks);
if (!context.mounted || selectedChainId == null) {
return null;
}
final target = chains.firstWhere(
(chain) => chain.chainId == selectedChainId,
orElse: () => chains.first,
);
final evaluation = await anyPayService.evaluateForEvmChain(request, target.chainId);
if (!context.mounted) {
return null;
}
return handleEvaluation(context, evaluation, fallbackCurrency: target.currency);
}
Future<AnyPayFormFill?> _handleCrossChain(
BuildContext context,
AnyPayRequest request,
AnyPayCrossChainPayment decision,
CryptoCurrency? fallbackCurrency,
) async {
final destinationType = decision.targetWalletType;
final isEvmTarget = isEVMCompatibleChain(destinationType);
final destinationNetworkName = networkDisplayName(destinationType, decision.targetChainId);
final destinationNetworkIcon = symbolIconPathForWalletType(destinationType) ?? "";
if (!decision.canSwap && !decision.hasCompatibleWallet) {
_showError(
context,
S.of(context).swap_unavailable_needs_network_wallet(destinationNetworkName),
);
return null;
}
final currentType = sendViewModel.wallet.type;
final currentChainId = isEVMCompatibleChain(currentType) ? _currentEvmChainId() : null;
final currentNetworkName = networkDisplayName(currentType, currentChainId);
final currentNetworkIcon = symbolIconPathForWalletType(currentType) ?? "";
if (!decision.hasCompatibleWallet) {
final swapConfirmTitle = isEvmTarget
? S.of(context).swap_from_network(currentNetworkName)
: S.of(context).network_address_detected(destinationNetworkName);
final swapConfirmPrimary = isEvmTarget
? S.of(context).continue_to_swap
: S.of(context).swap_from_network(currentNetworkName);
await Navigator.of(context).push<void>(
CupertinoPageRoute(
builder: (pageContext) => NetworkDecisionPage(
title: swapConfirmTitle,
description: S.of(context).swap_from_network_description(
destinationNetworkName,
currentNetworkName,
),
destinationIconPath: destinationNetworkIcon,
currentIconPath: currentNetworkIcon,
primaryText: swapConfirmPrimary,
primaryIconPath: isEvmTarget ? null : "assets/new-ui/swap_arrows.svg",
onPrimary: () => _openSwap(pageContext, request, decision),
secondaryText: S.of(context).cancel,
),
),
);
return null;
}
final decisionTitle = isEvmTarget
? S.of(context).send_to_network(destinationNetworkName)
: S.of(context).network_address_detected(destinationNetworkName);
final selectedWallet = await Navigator.of(context).push<WalletInfo>(
CupertinoPageRoute(
builder: (pageContext) => NetworkDecisionPage(
title: decisionTitle,
description: decision.canSwap
? S.of(context).send_to_network_description(
destinationNetworkName,
currentNetworkName,
)
: S.of(context).swap_unavailable_switch_to_network(destinationNetworkName),
destinationIconPath: destinationNetworkIcon,
primaryText: S.of(context).switch_to_x_wallet(destinationNetworkName),
primaryIconPath: "assets/new-ui/wallet_filled.svg",
onPrimary: () => _pickDestinationWallet(pageContext, decision, destinationNetworkName),
secondaryText: decision.canSwap
? S.of(context).swap_from_network(currentNetworkName)
: S.of(context).cancel,
secondaryIconPath: decision.canSwap ? "assets/new-ui/swap_arrows.svg" : null,
onSecondary: decision.canSwap ? () => _openSwap(pageContext, request, decision) : null,
),
),
);
if (selectedWallet == null || !context.mounted) {
return null;
}
return _switchWallet(context, selectedWallet, request, decision, fallbackCurrency);
}
Future<void> _pickDestinationWallet(
BuildContext pageContext,
AnyPayCrossChainPayment decision,
String networkName,
) async {
WalletInfo? destinationWalletInfo;
if (decision.wallets.length > 1) {
destinationWalletInfo = await SwitchNetworkWalletPage.push(
context: pageContext,
networkName: networkName,
targetIconPath: symbolIconPathForWalletType(decision.targetWalletType) ?? "",
wallets: decision.wallets,
);
} else {
destinationWalletInfo = decision.wallets.first;
}
if (destinationWalletInfo == null || !pageContext.mounted) {
return;
}
Navigator.of(pageContext).pop(destinationWalletInfo);
}
Future<AnyPayFormFill?> _switchWallet(
BuildContext context,
WalletInfo walletInfo,
AnyPayRequest request,
AnyPayCrossChainPayment decision,
CryptoCurrency? fallbackCurrency,
) async {
if (request.isLightning) {
_enterLightningMode();
}
_isSwitchingWallet = true;
BuildContext? loadingSheetContext;
bool completedFlow = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || completedFlow) {
return;
}
showModalBottomSheet<void>(
context: context,
isDismissible: false,
builder: (sheetContext) {
if (completedFlow) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (Navigator.canPop(sheetContext)) {
Navigator.of(sheetContext).pop();
}
});
return const SizedBox.shrink();
}
loadingSheetContext = sheetContext;
return LoadingBottomSheet(titleText: S.of(sheetContext).loading_your_wallet);
},
);
});
bool success = false;
try {
success = await anyPayService.switchWalletForPayment(
walletInfo,
chainId: decision.targetChainId,
);
} finally {
_isSwitchingWallet = false;
completedFlow = true;
if (loadingSheetContext != null &&
loadingSheetContext!.mounted &&
Navigator.canPop(loadingSheetContext!)) {
Navigator.of(loadingSheetContext!).pop();
}
loadingSheetContext = null;
}
if (!context.mounted) {
return null;
}
if (!success) {
_showError(context, S.of(context).network_switch_failed);
return null;
}
return AnyPayFormFill(
request: request,
token: decision.token,
fallbackCurrency: fallbackCurrency ?? request.detection.detectedCurrency,
amountOverride: decision.amountOverride,
);
}
Future<void> _openSwap(
BuildContext presentContext,
AnyPayRequest request,
AnyPayCrossChainPayment decision,
) async {
if (!presentContext.mounted) {
return;
}
if (request.hasContract && decision.token == null) {
_showError(presentContext, S.of(presentContext).unsupported_token_requested);
return;
}
final intent = anyPayService.buildSwapIntent(
request,
targetWalletType: decision.targetWalletType,
targetChainId: decision.targetChainId,
token: decision.token,
);
final page = NewSwapPage(
getIt.get<ExchangeViewModel>(),
authService,
getIt.get<AddressResolverService>(),
null,
walletSwitcherViewModel: walletSwitcherViewModel,
fromSend: SwapFromSendArgs.fromIntent(intent),
balanceViewModel: sendViewModel.balanceViewModel,
);
await Navigator.of(presentContext).push<void>(
CupertinoPageRoute(
builder: (context) => Material(
color: Theme.of(context).colorScheme.surface,
child: SafeArea(bottom: false, child: page),
),
),
);
}
void _showError(BuildContext context, String message) {
if (!context.mounted) {
return;
}
showPopUp<void>(
context: context,
builder: (context) => AlertWithOneAction(
alertTitle: S.of(context).error,
alertContent: message,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop(),
),
);
}
void _enterLightningMode() {
sendViewModel.selectedCryptoCurrency = CryptoCurrency.btcln;
sendViewModel.coinTypeToSpendFrom = UnspentCoinType.lightning;
}
int _currentEvmChainId() {
final wallet = sendViewModel.wallet;
return evm!.getSelectedChainId(wallet) ?? evm!.getChainIdByWalletType(wallet.type);
}
}
@@ -0,0 +1,131 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/anypay/select_recipient_network_sheet.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:flutter/material.dart";
class EvmAddressDetectedSheet extends StatelessWidget {
const EvmAddressDetectedSheet({super.key, required this.networks});
final List<RecipientNetworkItem> networks;
static Future<int?> show({
required BuildContext context,
required List<RecipientNetworkItem> networks,
}) {
return showModalBottomSheet<int>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => EvmAddressDetectedSheet(networks: networks),
);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
color: colors.surface,
),
child: SafeArea(
top: false,
child: Column(
children: [
ModalTopBar(
title: "",
trailingIcon: const Icon(Icons.close),
onTrailingPressed: () => Navigator.of(context).maybePop(),
),
const SizedBox(height: 32),
StackedNetworkIcons(iconPaths: networks.map((n) => n.iconPath).toList()),
const SizedBox(height: 24),
Text(
S.of(context).evm_address_detected,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontSize: 20, fontWeight: FontWeight.w500, letterSpacing: -0.1),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
S.of(context).select_recipient_network_for_tx,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
),
const SizedBox(height: 64),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: NetworkListCard(
rows: [
for (final network in networks)
RecipientNetworkListRow(
item: network,
imageOverride: network.chainBadgeIconPath,
onTap: () => Navigator.of(context).pop(network.chainId),
),
],
),
),
),
const SizedBox(height: 16),
],
),
),
);
}
}
class StackedNetworkIcons extends StatelessWidget {
const StackedNetworkIcons({
required this.iconPaths,
this.size = 50,
this.step = 34,
});
final List<String> iconPaths;
final double size;
final double step;
@override
Widget build(BuildContext context) {
if (iconPaths.isEmpty) return const SizedBox.shrink();
final colors = Theme.of(context).colorScheme;
final width = size + (iconPaths.length - 1) * step;
return SizedBox(
height: size,
width: width,
child: Stack(
children: [
for (int i = 0; i < iconPaths.length; i++)
Positioned(
left: i * step,
child: Container(
width: size,
height: size,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.surfaceContainer,
border: Border.all(color: colors.surface, width: 2),
),
child: HashImageWidget(imageUrl: iconPaths[i], fit: BoxFit.contain),
),
),
],
),
);
}
}
@@ -0,0 +1,174 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/new_primary_button.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:flutter/material.dart";
class NetworkDecisionPage extends StatelessWidget {
const NetworkDecisionPage({
required this.title,
required this.description,
required this.destinationIconPath,
required this.primaryText,
required this.onPrimary,
required this.secondaryText,
this.currentIconPath,
this.primaryIconPath,
this.secondaryIconPath,
this.onSecondary,
super.key,
});
final String title;
final String description;
final String destinationIconPath;
final String? currentIconPath;
final String primaryText;
final VoidCallback onPrimary;
final String? primaryIconPath;
final String secondaryText;
final String? secondaryIconPath;
final VoidCallback? onSecondary;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final primaryIcon = primaryIconPath;
final secondaryIcon = secondaryIconPath;
return Material(
color: colors.surface,
child: SafeArea(
child: Column(
children: [
ModalTopBar(
title: "",
leadingIcon: const Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context).maybePop(),
),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ExcludeSemantics(
child: _DecisionHeader(
destinationIconPath: destinationIconPath,
currentIconPath: currentIconPath,
),
),
const SizedBox(height: 24),
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontSize: 20,
fontWeight: FontWeight.w500,
letterSpacing: -0.1,
),
),
const SizedBox(height: 16),
Text(
description,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
],
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
NewPrimaryButton(
onPressed: onPrimary,
image: primaryIcon != null
? HashImageWidget(
imageUrl: primaryIcon,
width: 24,
height: 24,
colorFilter: ColorFilter.mode(colors.onPrimary, BlendMode.srcIn),
)
: null,
text: primaryText,
color: colors.primary,
textColor: colors.onPrimary,
),
const SizedBox(height: 12),
NewPrimaryButton(
onPressed: onSecondary ?? () => Navigator.of(context).maybePop(),
image: secondaryIcon != null
? HashImageWidget(
imageUrl: secondaryIcon,
width: 24,
height: 24,
colorFilter: ColorFilter.mode(colors.primary, BlendMode.srcIn),
)
: null,
text: secondaryText,
color: colors.surfaceContainer,
textColor: colors.primary,
),
],
),
),
],
),
),
);
}
}
class _DecisionHeader extends StatelessWidget {
const _DecisionHeader({required this.destinationIconPath, this.currentIconPath});
final String destinationIconPath;
final String? currentIconPath;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final current = currentIconPath;
if (current == null) {
return HashImageWidget(
imageUrl: destinationIconPath,
width: 75,
height: 75,
fit: BoxFit.contain,
color: isMonochromeSymbolIcon(destinationIconPath) ? colors.primary : null,
);
}
return Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
HashImageWidget(
imageUrl: current,
width: 50,
height: 50,
fit: BoxFit.contain,
color: isMonochromeSymbolIcon(current) ? colors.primary : null,
),
Icon(Icons.arrow_forward, color: colors.primary, size: 28),
HashImageWidget(
imageUrl: destinationIconPath,
width: 50,
height: 50,
fit: BoxFit.contain,
color: isMonochromeSymbolIcon(destinationIconPath) ? colors.primary : null,
),
],
);
}
}
@@ -0,0 +1,74 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:flutter/material.dart";
class RecipientNetworkSelector extends StatelessWidget {
const RecipientNetworkSelector({
required this.networkName,
required this.networkIconPath,
required this.onTap,
super.key,
});
final String networkName;
final String networkIconPath;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Semantics(
button: true,
label: "${S.of(context).network_prefix_on} $networkName",
child: GestureDetector(
onTap: onTap,
child: ExcludeSemantics(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
S.of(context).network_prefix_on,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
const SizedBox(width: 12),
HashImageWidget(
imageUrl: networkIconPath,
width: 16,
height: 16,
fit: BoxFit.cover,
color: isMonochromeSymbolIcon(networkIconPath) ? colors.primary : null,
),
const SizedBox(width: 8),
Text(
networkName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w400,
letterSpacing: -0.07,
color: colors.primary,
),
),
const SizedBox(width: 8),
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: colors.surfaceContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.keyboard_arrow_down,
size: 18,
color: colors.onSurfaceVariant,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,243 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:flutter/material.dart";
class RecipientNetworkItem {
const RecipientNetworkItem({
required this.chainId,
required this.name,
required this.iconPath,
this.chainBadgeIconPath,
});
final int chainId;
final String name;
final String iconPath;
final String? chainBadgeIconPath;
}
class SelectRecipientNetworkSheet extends StatelessWidget {
const SelectRecipientNetworkSheet({
super.key,
required this.networks,
required this.currentChainId,
});
final List<RecipientNetworkItem> networks;
final int currentChainId;
static Future<int?> show({
required BuildContext context,
required List<RecipientNetworkItem> networks,
required int currentChainId,
}) {
return showModalBottomSheet<int>(
context: context,
isScrollControlled: true,
useSafeArea: true,
enableDrag: false,
isDismissible: false,
backgroundColor: Colors.transparent,
builder: (_) => SelectRecipientNetworkSheet(
networks: networks,
currentChainId: currentChainId,
),
);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
RecipientNetworkItem? current;
final compatible = <RecipientNetworkItem>[];
for (final network in networks) {
if (network.chainId == currentChainId && current == null) {
current = network;
} else {
compatible.add(network);
}
}
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
color: colors.surface,
),
child: SafeArea(
top: false,
child: Column(
children: [
ModalTopBar(
title: "",
trailingIcon: const Icon(Icons.close),
onTrailingPressed: () => Navigator.of(context).maybePop(),
),
const SizedBox(height: 32),
HashImageWidget(
imageUrl: "assets/new-ui/network_cube.svg",
width: 75,
height: 75,
colorFilter: ColorFilter.mode(colors.onSurfaceVariant, BlendMode.srcIn),
),
const SizedBox(height: 24),
Text(
S.of(context).select_recipient_network,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontSize: 20, fontWeight: FontWeight.w500, letterSpacing: -0.1),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
S.of(context).select_recipient_network_description,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
),
const SizedBox(height: 32),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (current != null) ...[
NetworkListCard(
rows: [
RecipientNetworkListRow(
item: current,
subtitle: S.of(context).current_network,
onTap: () => Navigator.of(context).pop(current!.chainId),
),
],
),
const SizedBox(height: 24),
],
if (compatible.isNotEmpty) ...[
Text(
S.of(context).compatible_networks,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.06),
),
const SizedBox(height: 12),
NetworkListCard(
rows: [
for (final network in compatible)
RecipientNetworkListRow(
item: network,
onTap: () => Navigator.of(context).pop(network.chainId),
),
],
),
],
const SizedBox(height: 16),
],
),
),
),
],
),
),
);
}
}
class NetworkListCard extends StatelessWidget {
const NetworkListCard({required this.rows});
final List<Widget> rows;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(20),
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < rows.length; i++) ...[
rows[i],
if (i != rows.length - 1)
Divider(
height: 1,
thickness: 1,
color: colors.surfaceContainerHigh,
indent: 48,
endIndent: 12,
),
],
],
),
);
}
}
class RecipientNetworkListRow extends StatelessWidget {
const RecipientNetworkListRow(
{super.key, required this.item, required this.onTap, this.subtitle, this.imageOverride});
final String? subtitle;
final String? imageOverride;
final VoidCallback onTap;
final RecipientNetworkItem item;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Row(
children: [
HashImageWidget(
imageUrl: imageOverride ?? item.iconPath,
width: 24,
height: 24,
fit: BoxFit.cover,
color: isMonochromeSymbolIcon(imageOverride ?? item.iconPath) ? colors.primary : null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
item.name,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.w400, letterSpacing: -0.07),
),
if (subtitle != null)
Text(
subtitle!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant, letterSpacing: -0.06, fontSize: 12),
),
],
),
),
Icon(Icons.chevron_right, size: 16, color: colors.onSurfaceVariant),
],
),
),
);
}
}
@@ -0,0 +1,108 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/new-ui/widgets/send_page/l2_action_wallet_selector.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/wallet_info.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
class SwitchNetworkWalletPage extends StatelessWidget {
const SwitchNetworkWalletPage({
required this.wallets,
required this.networkName,
required this.destinationIconPath,
super.key,
});
final String networkName;
final String destinationIconPath;
final List<WalletInfo> wallets;
static Future<WalletInfo?> push({
required BuildContext context,
required String networkName,
required String targetIconPath,
required List<WalletInfo> wallets,
}) =>
Navigator.of(context).push<WalletInfo>(
CupertinoPageRoute(
builder: (_) => SwitchNetworkWalletPage(
networkName: networkName,
destinationIconPath: targetIconPath,
wallets: wallets,
),
),
);
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Material(
color: colors.surface,
child: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ModalTopBar(
title: "",
leadingIcon: const Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: () => Navigator.of(context).maybePop(),
),
const SizedBox(height: 124),
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.arrow_forward, color: colors.primary, size: 32),
const SizedBox(width: 8),
HashImageWidget(
imageUrl: destinationIconPath,
width: 50,
height: 50,
fit: BoxFit.contain,
color: isMonochromeSymbolIcon(destinationIconPath) ? colors.primary : null,
),
],
),
const SizedBox(height: 24),
Text(
S.of(context).switch_to_x_wallet(networkName),
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontSize: 20, fontWeight: FontWeight.w500, letterSpacing: -0.1),
),
const SizedBox(height: 24),
Text(
S.of(context).select_compatible_wallet,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: colors.onSurfaceVariant, letterSpacing: -0.07),
),
const SizedBox(height: 24),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: wallets.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (_, index) {
final wallet = wallets[index];
return WalletRow(
currencyIconPath: getCryptoCurrencyIconForWalletListItem(wallet.type),
walletName: wallet.name,
onTap: () => Navigator.of(context).pop(wallet),
);
},
),
),
],
),
),
);
}
}
+71 -53
View File
@@ -1,3 +1,4 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/themes/core/theme_extension.dart';
import 'package:flutter/material.dart';
@@ -60,67 +61,78 @@ class AppsWidget extends StatelessWidget {
end: Alignment.bottomCenter,
),
),
child: TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
shape: RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(
width: 1.25, color: Theme.of(context).colorScheme.surfaceContainerHigh),
),
padding: EdgeInsets.all(24),
),
child: Column(
children: [
Row(
// The card is one node; the arrow glyph's "leaves the app" meaning
// becomes a hint instead of an unlabeled icon.
child: MergeSemantics(
child: Semantics(
hint: isLink == true ? S.of(context).opens_externally : null,
child: TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
shape: RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(
width: 1.25, color: Theme.of(context).colorScheme.surfaceContainerHigh),
),
padding: EdgeInsets.all(24),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(right: 20),
child: HashImageWidget(imageUrl: image, height: 54, width: 54),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 6.0,
Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 20),
child: ExcludeSemantics(
child: HashImageWidget(imageUrl: image, height: 54, width: 54),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 6.0,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w800,
fontSize: 20,
),
softWrap: true,
),
SizedBox(),
],
),
SizedBox(height: 5),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w800,
fontSize: 20,
subTitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
softWrap: true,
),
SizedBox(),
],
),
SizedBox(height: 5),
Text(
subTitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
softWrap: true,
),
ExcludeSemantics(
child: Icon(
isLink == true ? Icons.arrow_outward : Icons.arrow_forward_ios,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 20,
),
],
),
)
],
),
Icon(
isLink == true ? Icons.arrow_outward : Icons.arrow_forward_ios,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 20,
)
if (hint != null) ...[
SizedBox(height: 10),
hint!,
]
],
),
if (hint != null) ...[
SizedBox(height: 10),
hint!,
]
],
),
),
),
),
@@ -128,10 +140,16 @@ class AppsWidget extends StatelessWidget {
Positioned(
top: 10,
right: 10,
child: IconButton(
icon: Icon(Icons.close),
onPressed: onClose,
//color: Theme.of(context).colorScheme.onSurface,
// Label the icon-only button without adding a visible tooltip.
child: MergeSemantics(
child: Semantics(
label: S.of(context).close,
child: IconButton(
icon: Icon(Icons.close),
onPressed: onClose,
//color: Theme.of(context).colorScheme.onSurface,
),
),
),
),
],
+54
View File
@@ -0,0 +1,54 @@
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:flutter/material.dart";
class BorderedSvgIcon extends StatelessWidget {
const BorderedSvgIcon({
required this.iconPath,
required this.iconSize,
Color? iconColor,
Color? borderColor,
this.borderSize = 6,
super.key,
}) : _iconColor = iconColor,
_borderColor = borderColor;
final String iconPath;
final Color? _iconColor;
final double iconSize;
final Color? _borderColor;
final double borderSize;
@override
Widget build(BuildContext context) {
final iconColor = _iconColor ?? Theme.of(context).colorScheme.onSurface;
final borderColor = _borderColor ?? Theme.of(context).colorScheme.surface;
return SizedBox(
width: iconSize + borderSize * 2,
height: iconSize + borderSize * 2,
child: Stack(
alignment: Alignment.center,
children: [
HashImageWidget(
imageUrl: iconPath,
width: iconSize + borderSize * 2,
height: iconSize + borderSize * 2,
colorFilter: ColorFilter.mode(
borderColor,
BlendMode.srcIn,
),
),
HashImageWidget(
imageUrl: iconPath,
width: iconSize,
height: iconSize,
colorFilter: ColorFilter.mode(
iconColor,
BlendMode.srcIn,
),
),
],
),
);
}
}
@@ -88,7 +88,8 @@ class TransferHistoryRow extends StatelessWidget {
child: Stack(
children: [
HashImageWidget(
imageUrl: 'assets/images/crypto/${sourceChainName?.toLowerCase()}.webp',
imageUrl:
'assets/new-ui/crypto_full_icons/${sourceChainName?.toLowerCase()}.svg',
width: 28,
height: 28,
),
@@ -96,7 +97,8 @@ class TransferHistoryRow extends StatelessWidget {
top: 14,
left: 12,
child: HashImageWidget(
imageUrl: 'assets/images/crypto/${destChainName?.toLowerCase()}.webp',
imageUrl:
'assets/new-ui/crypto_full_icons/${destChainName?.toLowerCase()}.svg',
width: 28,
height: 28,
),
@@ -0,0 +1,154 @@
import "package:hash_wallet/di.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/main.dart";
import "package:hash_wallet/new-ui/modal_navigator.dart";
import "package:hash_wallet/new-ui/pages/buy_sell/buy_sell_amount_page.dart";
import "package:hash_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/themes/core/theme_extension.dart";
import "package:hash_wallet/view_model/buy/buy_sell_view_model.dart";
import "package:flutter/material.dart";
class BuySellSelectorModal extends StatelessWidget {
const BuySellSelectorModal({super.key});
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(18)),
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceDim,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
top: false,
child: Column(
spacing: 24,
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox.shrink(),
Text(
S.of(context).buy_or_sell,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
),
Text(
S.of(context).buy_or_sell_desc,
style:
TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Column(
spacing: 12,
children: [
BuySellSelectorModalButton(
title: S.of(context).buy_crypto,
description: S.of(context).buy_crypto_desc,
iconPath: "assets/new-ui/plus.svg",
onTap: () => openBuySellPage(context, BuySellPageMode.buy),
),
BuySellSelectorModalButton(
title: S.of(context).sell_crypto,
description: S.of(context).sell_crypto_desc,
iconPath: "assets/new-ui/sell.svg",
onTap: () => openBuySellPage(context, BuySellPageMode.sell),
),
],
),
),
const SizedBox.shrink(),
],
),
),
);
void openBuySellPage(BuildContext context, BuySellPageMode mode) {
Navigator.of(context).pop();
final page = getIt.get<NewBuySellAmountPage>(param1: NewBuySellParams(mode: mode));
showModalBottomSheet(
useSafeArea: true,
isScrollControlled: true,
context: navigatorKey.currentContext!,
builder: (modalContext) => ModalNavigator(
rootPage: page,
parentContext: modalContext,
),
);
}
}
class BuySellSelectorModalButton extends StatelessWidget {
const BuySellSelectorModalButton({
required this.title,
required this.description,
required this.iconPath,
required this.onTap,
super.key,
});
final String title;
final String description;
final String iconPath;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
width: 1,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
gradient: LinearGradient(
colors: [
context.customColors.cardGradientColorPrimary,
context.customColors.cardGradientColorSecondary,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Row(
spacing: 20,
children: [
HashImageWidget(
imageUrl: iconPath,
width: 55,
height: 55,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant,
BlendMode.srcIn,
),
),
Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 16),
),
Text(
description,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
),
);
}
+40 -30
View File
@@ -5,6 +5,7 @@ import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/wallet_type_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
@@ -40,15 +41,16 @@ class _ChangelogModalState extends State<ChangelogModal> {
}
void loadChangelog() async {
final fileName = isMoneroOnly ? "monerocom_changelog" : "changelog";
String lang = WidgetsBinding.instance.platformDispatcher.locale.languageCode;
if (lang == "und" ||
lang.isEmpty ||
!(await File("$changelogTextLocation/changelog_$lang.json").exists())) {
!(await File("$changelogTextLocation/${fileName}_$lang.json").exists())) {
lang = "en";
}
final List<dynamic> changelog =
jsonDecode(await rootBundle.loadString("$changelogTextLocation/changelog_$lang.json"))
jsonDecode(await rootBundle.loadString("$changelogTextLocation/${fileName}_$lang.json"))
as List<dynamic>;
for (final item in changelog) {
@@ -170,34 +172,42 @@ class ChangelogItemWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
if (item.iconFilename.isNotEmpty)
HashImageWidget(
imageUrl: "$changelogIconLocation/${item.iconFilename}.svg",
height: 36,
width: 36,
colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (item.title.isNotEmpty)
Text(
item.title,
style: TextStyle(fontWeight: FontWeight.w500),
),
if (item.description.isNotEmpty)
Text(item.description,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
))
],
)
],
return Padding(
padding: EdgeInsets.symmetric(horizontal: 18),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
if (item.iconFilename.isNotEmpty)
HashImageWidget(
imageUrl: "$changelogIconLocation/${item.iconFilename}.svg",
height: 36,
width: 36,
colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (item.title.isNotEmpty)
Text(
item.title,
style: TextStyle(fontWeight: FontWeight.w500),
),
if (item.description.isNotEmpty)
Text(
item.description,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
],
),
)
],
),
);
}
}
@@ -0,0 +1,228 @@
import "dart:async";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/model/charts/util/price_change_data.dart";
import "package:hash_wallet/new-ui/model/charts/util/price_change_direction.dart";
import "package:hash_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/change_pill.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/chart_modal.dart";
import "package:hash_wallet/new-ui/widgets/long_press_menu/long_press_footer.dart";
import "package:hash_wallet/new-ui/widgets/long_press_menu/long_press_menu.dart";
import "package:hash_wallet/new-ui/widgets/long_press_menu/long_press_popup.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_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({required this.bloc, super.key});
final ChartsBloc bloc;
@override
Widget build(BuildContext context) => BlocBuilder<ChartsBloc, ChartsState>(
bloc: bloc,
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(
bloc: bloc,
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(
{required this.bloc, 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;
final ChartsBloc bloc;
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: () {
bloc.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) {
bloc.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(
bloc: bloc,
currency: currency,
isFavorite: favorite,
),);
if (res != null && res is bool && res) {
bloc.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: HashImageWidget(
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: [
HashImageWidget(
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,87 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/model/charts/util/price_data_sort_criteria.dart";
import "package:hash_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
import "package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart";
import "package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_sheet.dart";
import "package:hash_wallet/new-ui/widgets/long_press_menu/long_press_menu.dart";
import "package:hash_wallet/new-ui/widgets/long_press_menu/long_press_popup.dart";
import "package:hash_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({
required this.bloc, super.key,
});
final ChartsBloc bloc;
@override
Widget build(BuildContext context) => BlocBuilder<ChartsBloc, ChartsState>(
bloc: bloc,
builder: (context, state) {
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:hash_wallet/new-ui/model/charts/util/price_change_data.dart';
import 'package:hash_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:hash_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,164 @@
import "package:hash_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/change_display.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/chart_view.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/coin_header.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/price_header.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/range_selector.dart";
import "package:hash_wallet/src/widgets/hash_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({
required this.bloc, 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;
final ChartsBloc bloc;
@override
State<ChartHeader> createState() => _ChartHeaderState();
}
class _ChartHeaderState extends State<ChartHeader> {
String? _viewedPrice;
String? _viewedTime;
@override
Widget build(BuildContext context) => BlocBuilder<ChartsBloc, ChartsState>(
bloc: widget.bloc,
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)
HashImageWidget(
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) =>
widget.bloc.add(RangeChanged(newRange: range)),
),
],
),
],
);
}
return const CupertinoActivityIndicator();
},
);
}
@@ -0,0 +1,156 @@
import "package:hash_wallet/di.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/main.dart";
import "package:hash_wallet/new-ui/modal_navigator.dart";
import "package:hash_wallet/new-ui/pages/buy_sell/buy_sell_amount_page.dart";
import "package:hash_wallet/new-ui/pages/swap_page.dart";
import "package:hash_wallet/new-ui/viewmodels/charts/charts_bloc.dart";
import "package:hash_wallet/new-ui/widgets/charts_page/chart_header.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/action_row/coin_action_button.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/routes.dart";
import "package:hash_wallet/src/screens/buy/buy_sell_page.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/store/app_store.dart";
import "package:hash_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.bloc, required this.currency, required this.isFavorite, super.key});
final CryptoCurrency currency;
final bool isFavorite;
final ChartsBloc bloc;
@override
Widget build(BuildContext context) => 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(
bloc: bloc,
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: HashImageWidget(
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: HashImageWidget(
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: HashImageWidget(
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: HashImageWidget(
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:hash_wallet/new-ui/model/charts/price_data.dart";
import "package:hash_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:hash_wallet/src/widgets/hash_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)
HashImageWidget(
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)
HashImageWidget(
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:hash_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(),
),
],
);
}
}
@@ -18,6 +18,7 @@ class CoinControlListItem extends StatelessWidget {
required this.isLast,
required this.isLoading,
this.onCheckBoxTap,
required this.hasCheckbox,
});
final String note;
@@ -31,6 +32,7 @@ class CoinControlListItem extends StatelessWidget {
final bool isFirst;
final bool isLast;
final bool isLoading;
final bool hasCheckbox;
final Function()? onCheckBoxTap;
@override
@@ -133,6 +135,10 @@ class CoinControlListItem extends StatelessWidget {
return HashImageWidget(imageUrl: "assets/new-ui/frozen.svg");
}
if (!hasCheckbox) {
return SizedBox.shrink();
}
return NewSimpleCheckbox(
value: isSending,
onChanged: (value) {
@@ -0,0 +1,109 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/store/settings_store.dart";
import "package:cw_core/wallet_type.dart";
import "package:flutter/material.dart";
import "package:flutter_mobx/flutter_mobx.dart";
class AccountsPromo extends StatelessWidget {
const AccountsPromo({
required this.settingsStore,
required this.walletName,
required this.onTap,
super.key,
});
final SettingsStore settingsStore;
final String walletName;
final VoidCallback onTap;
static bool supportsWallet(WalletType walletType) => walletType == WalletType.bitcoin;
@override
Widget build(BuildContext context) => Observer(
builder: (_) {
if (settingsStore.accountsHomePromoDismissed) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
Material(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: 64),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
const HashImageWidget(
imageUrl: "assets/new-ui/settings_row_icons/accounts.svg",
width: 24,
height: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
S.of(context).accounts_home_promo_title(walletName),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
S.of(context).accounts_home_promo_description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward_ios,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
),
),
const SizedBox(height: 12),
Semantics(
button: true,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => settingsStore.accountsHomePromoDismissed = true,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
S.of(context).do_not_show_anymore,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
),
),
),
],
),
);
},
);
}
@@ -1,21 +1,22 @@
import 'dart:math';
import "dart:math";
import 'package:hash_wallet/themes/core/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/svg.dart';
import "package:hash_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;
@@ -24,47 +25,58 @@ class CoinActionButton extends StatelessWidget {
final size = MediaQuery.of(context).size.width * sizeFactor;
final double effectiveSize = min(size, 80);
return Column(
children: [
Container(
width: effectiveSize,
height: effectiveSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
context.customColors.cardGradientColorPrimary,
context.customColors.cardGradientColorSecondary
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
border: Border.all(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
width: 1,
),
),
child: IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
HapticFeedback.mediumImpact();
action();
},
icon: icon,
color: Theme.of(context).colorScheme.primary,
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
// The caption below the circle is the accessible name, so it is excluded
// from the semantics tree to keep this a single button node.
return MergeSemantics(
child: Semantics(
button: true,
label: label,
child: Column(
children: [
Container(
width: effectiveSize,
height: effectiveSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: gradientColors ??
[
context.customColors.cardGradientColorPrimary,
context.customColors.cardGradientColorSecondary,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
label,
),
border: Border.all(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
width: 1,
),
),
child: IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
HapticFeedback.mediumImpact();
action();
},
icon: ExcludeSemantics(child: icon),
color: Theme.of(context).colorScheme.primary,
),
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: ExcludeSemantics(
child: Text(
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
label,
),
),
),
],
),
],
),
);
}
}
@@ -17,14 +17,13 @@ import 'package:hash_wallet/view_model/send/send_view_model.dart';
import 'package:hash_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/lnurl.dart';
import 'package:cw_core/node.dart';
import 'package:cw_core/unspent_coin_type.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import '../../../pages/receive_page.dart';
import '../../../pages/scan_page.dart';
import 'coin_action_button.dart';
class CoinActionRow extends StatelessWidget {
@@ -45,6 +44,7 @@ class CoinActionRow extends StatelessWidget {
spacing: MediaQuery.of(context).size.width * 0.05,
children: [
CoinActionButton(
key: ValueKey("home_page_send_button_key"),
icon: HashImageWidget(
imageUrl: "assets/new-ui/send.svg",
colorFilter: ColorFilter.mode(
@@ -54,7 +54,6 @@ class CoinActionRow extends StatelessWidget {
),
label: S.of(context).send,
action: () {
if (FeatureFlag.hasNewUiExtraPages) {
final sendPage = getIt.get<NewSendPage>(
param1: SendPageParams(
unspentCoinType:
@@ -75,14 +74,10 @@ class CoinActionRow extends StatelessWidget {
);
},
);
} else {
Map<String, dynamic>? args;
if (lightningMode) args = {'coinTypeToSpendFrom': UnspentCoinType.lightning};
Navigator.of(context).pushNamed(Routes.send, arguments: args);
}
},
),
CoinActionButton(
key: ValueKey("home_page_receive_button_key"),
icon: HashImageWidget(
imageUrl: "assets/new-ui/receive.svg",
colorFilter: ColorFilter.mode(
@@ -92,7 +87,6 @@ class CoinActionRow extends StatelessWidget {
),
label: S.of(context).receive,
action: () async {
if (FeatureFlag.hasNewUiExtraPages) {
final page = getIt.get<NewReceivePage>(param1: lightningMode);
CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
@@ -101,21 +95,11 @@ class CoinActionRow extends StatelessWidget {
return Material(child: ModalNavigator(parentContext: context, rootPage: page));
},
);
} else {
// ToDo: (Konsti) refactor as part of the derivation PR (I hate myself for it)
if (lightningMode) {
await getIt<WalletAddressListViewModel>().setAddressType(
bitcoin!.getOptionToType(bitcoin!.getBitcoinLightningReceivePageOption()));
} else {
await getIt<WalletAddressListViewModel>().setAddressType(
bitcoin!.getOptionToType(bitcoin!.getBitcoinSegwitPageOption()));
}
Navigator.of(context).pushNamed(Routes.addressPage);
}
},
),
if (showSwap)
CoinActionButton(
key: ValueKey("home_page_swap_button_key"),
icon: HashImageWidget(
imageUrl: "assets/new-ui/exchange.svg",
colorFilter: ColorFilter.mode(
@@ -127,7 +111,6 @@ class CoinActionRow extends StatelessWidget {
action: () {
final page =
getIt.get<NewSwapPage>(param2: lightningMode ? CryptoCurrency.btcln : null);
if (FeatureFlag.hasNewUiExtraPages) {
CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(85),
@@ -137,12 +120,10 @@ class CoinActionRow extends StatelessWidget {
parentContext: context,
)),
);
} else {
Navigator.of(context).pushNamed(Routes.exchange);
}
},
),
CoinActionButton(
key: ValueKey("home_page_scan_button_key"),
icon: HashImageWidget(
imageUrl: "assets/new-ui/scan.svg",
colorFilter: ColorFilter.mode(
@@ -159,71 +140,63 @@ class CoinActionRow extends StatelessWidget {
}
Future<void> _onPressedScan(BuildContext context) async {
if (false && FeatureFlag.hasNewUiExtraPages) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => FractionallySizedBox(
heightFactor: 0.9,
child: ScanPage(),
),
);
} else {
final code = await presentQRScanner(context);
final code = await presentQRScanner(context, showHelp: true);
if (code == null || code.isEmpty) return;
if (code == null || code.isEmpty) return;
late final PaymentRequest req;
var unspentCoinType = UnspentCoinType.any;
if (SendViewModelBase.isNonZeroAmountLightningInvoice(code)) {
unspentCoinType = UnspentCoinType.lightning;
final amount = CryptoCurrency.btcln.formatAmount(BigInt.from(getBolt11Amount(code) ?? 0));
req = PaymentRequest(code, amount, "", "", "");
} else if (SendViewModelBase.isLnurlInvoice(code)) {
unspentCoinType = UnspentCoinType.lightning;
final amount = CryptoCurrency.btcln
.formatAmount(BigInt.from(await LNURL.getPayRequestAmount(code) ?? 0));
req = PaymentRequest(code, amount, "", "", "");
} else if (OpenCryptoPayService.isOpenCryptoPayQR(code)) {
req = PaymentRequest(code, "", "", "", "");
} else if (Uri.tryParse(code)?.scheme == "wc") {
if (!isEVMCompatibleChain(walletType)) {
showPopUp<void>(
context: context,
builder: (context) => AlertWithOneAction(
alertTitle: "WalletConnect",
alertContent: S.of(context).switchToEVMCompatibleWallet,
buttonText: "OK",
buttonAction: Navigator.of(context).pop));
return;
}
Navigator.of(context)
.pushNamed(Routes.walletConnectConnectionsListing, arguments: Uri.parse(code));
PaymentRequest? req;
String? rawInput;
var unspentCoinType = UnspentCoinType.any;
if (SendViewModelBase.isNonZeroAmountLightningInvoice(code)) {
unspentCoinType = UnspentCoinType.lightning;
final amount = getBolt11Amount(code)?.toString() ?? "0";
req = PaymentRequest(code, amount, "", "", "");
} else if (SendViewModelBase.isLnurlInvoice(code)) {
unspentCoinType = UnspentCoinType.lightning;
final amount = (await LNURL.getPayRequestAmount(code))?.toString() ?? "0";
req = PaymentRequest(code, amount, "", "", "");
} else if (Uri.tryParse(code)?.scheme == "wc") {
if (!isWalletConnectCompatibleChain(walletType)) {
showPopUp<void>(
context: context,
builder: (context) => AlertWithOneAction(
alertTitle: "WalletConnect",
alertContent:
S.of(context).switchToWCCompatibleWallet(walletConnectCompatibleChainsLabel()),
buttonText: "OK",
buttonAction: Navigator.of(context).pop));
return;
} else {
final uri = Uri.tryParse(code);
if (uri == null) return;
req = PaymentRequest.fromUri(uri);
}
final sendPage = getIt.get<NewSendPage>(
param1: SendPageParams(
initialPaymentRequest: req,
unspentCoinType: unspentCoinType,
),
);
CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(60),
builder: (context) => Material(
child: ModalNavigator(
rootPage: sendPage,
parentContext: context,
),
),
);
Navigator.of(context)
.pushNamed(Routes.walletConnectConnectionsListing, arguments: Uri.parse(code));
return;
} else if (!OpenCryptoPayService.isOpenCryptoPayQR(code) &&
["http", "https", "tcp"].contains(Uri.tryParse(code)?.scheme)) {
Navigator.of(context).pushNamed(Routes.newNode,
arguments: {"editingNode": Node.fromUri(Uri.parse(code), walletType)});
return;
} else {
rawInput = code;
}
final sendPage = getIt.get<NewSendPage>(
param1: SendPageParams(
initialPaymentRequest: req,
initialRawInput: rawInput,
unspentCoinType: unspentCoinType,
),
);
CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(60),
builder: (context) => Material(
child: ModalNavigator(
rootPage: sendPage,
parentContext: context,
),
),
);
}
}
@@ -6,6 +6,7 @@ import 'package:hash_wallet/new-ui/pages/bridge/bridge_amount_page.dart';
import 'package:hash_wallet/new-ui/pages/receive_page.dart';
import 'package:hash_wallet/new-ui/pages/send_page.dart';
import 'package:hash_wallet/new-ui/pages/swap_page.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:hash_wallet/new-ui/widgets/modern_button.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
@@ -61,6 +62,7 @@ class AssetDetailsModal extends StatelessWidget {
ModalTopBar(
title: "",
trailingIcon: Icon(Icons.close),
trailingSemanticLabel: S.of(context).close,
onTrailingPressed: Navigator.of(context).pop,
padding: EdgeInsets.only(top: 12, right: 18),
),
@@ -77,7 +79,10 @@ class AssetDetailsModal extends StatelessWidget {
child: Stack(
children: [
if (iconPath.isNotEmpty)
HashImageWidget(imageUrl: iconPath, width: 75, height: 75)
TokenImageWidget(
imageUrl: iconPath,
size: 75,
)
else
Container(
width: 75,
@@ -352,17 +357,22 @@ class AssetDetailsModalBottomButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
spacing: 8,
children: [
ModernButton.svg(
onPressed: onPressed,
svgPath: iconPath,
size: 60,
iconSize: 32,
),
Text(title)
],
// The caption is the button's accessible name, so it is excluded from the
// semantics tree to keep the whole control a single button node.
return MergeSemantics(
child: Column(
spacing: 8,
children: [
ModernButton.svg(
onPressed: onPressed,
svgPath: iconPath,
size: 60,
iconSize: 32,
semanticLabel: title,
),
ExcludeSemantics(child: Text(title))
],
),
);
}
}
@@ -1,11 +1,11 @@
import 'dart:math' show min;
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:hash_wallet/src/screens/wallet_connect/utils/string_parsing.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/view_model/dashboard/balance_view_model.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
class AssetTile extends StatelessWidget {
@@ -37,147 +37,140 @@ class AssetTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final iconPath = _getIconPath();
final iconPath = balance.asset.iconPath ?? "";
return GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) {
return AssetDetailsModal(
showSwap: showSwap,
showBridgeButton: showBridgeButton,
asset: balance.asset,
title: title ?? balance.asset.fullName ?? balance.asset.name,
chainTitle: "",
subtitle: trailingText ?? _getChainTitle(),
amount: showSecondary ? balance.secondAvailableBalance : balance.availableBalance,
currencyTitle: balance.asset.title,
fiatAmount: showSecondary
? balance.fiatSecondAvailableBalance
: balance.fiatAvailableBalance,
iconPath: balance.asset.iconPath ?? "",
chainIconPath: chainIconPath,
mode: modalMode,
wallet: wallet,
);
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Container(
width: double.infinity,
height: 72,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.vertical(
top: isFirst ? Radius.circular(18) : Radius.zero,
bottom: isLast ? Radius.circular(18) : Radius.zero,
),
),
// The row is one control: name, amount and fiat value merge into a single
// button node that opens the asset details sheet.
return MergeSemantics(
child: Semantics(
button: true,
child: GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) {
return AssetDetailsModal(
showSwap: showSwap,
showBridgeButton: showBridgeButton,
asset: balance.asset,
title: title ?? balance.asset.fullName ?? balance.asset.name,
chainTitle: "",
subtitle: trailingText ?? _getChainTitle(),
amount:
showSecondary ? balance.secondAvailableBalance : balance.availableBalance,
currencyTitle: balance.asset.title,
fiatAmount: showSecondary
? balance.fiatSecondAvailableBalance
: balance.fiatAvailableBalance,
iconPath: balance.asset.iconPath ?? "",
chainIconPath: chainIconPath,
mode: modalMode,
wallet: wallet,
);
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 36,
child: Stack(
children: [
if ((iconPath).isNotEmpty)
HashImageWidget(imageUrl: iconPath)
else
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Container(
width: double.infinity,
height: 72,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.vertical(
top: isFirst ? Radius.circular(18) : Radius.zero,
bottom: isLast ? Radius.circular(18) : Radius.zero,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Decorative: the asset name is already in the row text.
ExcludeSemantics(
child: iconPath.isNotEmpty
? TokenImageWidget(
imageUrl: iconPath,
size: 36,
)
: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(99999)),
child: Center(
shape: BoxShape.circle,
),
child: Center(
child: Text(
balance.asset.name
.substring(0, min(2, balance.asset.name.length)),
style: TextStyle(
fontSize: 20,
color: Theme.of(context).colorScheme.onPrimary),
)),
),
// if (chainIconPath.isNotEmpty)
// Align(
// alignment: Alignment.bottomRight,
// child: Container(
// decoration: ShapeDecoration(
// shape: RoundedSuperellipseBorder(
// borderRadius: BorderRadius.circular(5),side: BorderSide(color: Colors.black)),
// color: Colors.white),
// child: Padding(
// padding: const EdgeInsets.all(2.0),
// child: HashImageWidget(
// imageUrl: chainIconPath,
// width: 12,
// height: 12,
// colorFilter:
// ColorFilter.mode(Colors.black, BlendMode.srcIn),
// ),
// )))
],
)),
SizedBox(width: 12.0),
Expanded(
child: Column(
spacing: 4.0,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 4,
children: [
Text(
title ?? balance.asset.fullName ?? balance.asset.name,
style: TextStyle(fontWeight: FontWeight.w500),
),
if (trailingText != null)
Text(
trailingText!,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant),
balance.asset.name
.substring(0, min(2, balance.asset.name.length)),
style: TextStyle(
fontSize: 20,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
),
),
SizedBox(width: 12.0),
Expanded(
child: Column(
spacing: 4.0,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 4,
children: [
Text(
title ?? balance.asset.fullName ?? balance.asset.name,
style: TextStyle(fontWeight: FontWeight.w500),
),
if (trailingText != null)
Text(
trailingText!,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
),
Padding(
padding: const EdgeInsets.only(right: 4.0),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
"${showSecondary ? balance.secondAvailableBalance : balance.availableBalance} ${balance.formattedAssetTitle.safeSubString(0, 6)}",
maxLines: 1,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
],
),
Padding(
padding: const EdgeInsets.only(right: 4.0),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
"${showSecondary ? balance.secondAvailableBalance : balance.availableBalance} ${balance.formattedAssetTitle.safeSubString(0, 6)}",
maxLines: 1,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
],
),
),
],
),
],
),
),
Text(
showSecondary
? balance.fiatSecondAvailableBalance
: balance.fiatAvailableBalance,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
Text(
showSecondary ? balance.fiatSecondAvailableBalance : balance.fiatAvailableBalance,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
),
),
@@ -185,17 +178,5 @@ class AssetTile extends StatelessWidget {
);
}
String _getChainTitle() {
try {
return CryptoCurrency.fromString(wallet.currency.tag ?? wallet.currency.title).fullName ?? "";
} catch (e) {
return wallet.currency.fullName ?? "";
}
}
String _getIconPath() {
if (balance.asset == CryptoCurrency.baseEth) return "assets/new-ui/crypto_full_icons/base.svg";
return balance.asset.iconPath ?? "";
}
String _getChainTitle() => walletTypeToDisplayName(wallet.type);
}
@@ -1,19 +1,34 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/modal_navigator.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_modal.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_top_bar.dart';
import 'package:hash_wallet/reactions/wallet_connect.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/screens/dashboard/pages/nft_listing_page.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import 'assets_section.dart';
import 'history_section.dart';
class AssetsHistorySectionActionButton {
final String title;
final String iconPath;
final VoidCallback onPressed;
const AssetsHistorySectionActionButton(this.title, this.iconPath, this.onPressed);
}
class AssetsHistorySectionTab {
final String title;
final Widget content;
final AssetsHistorySectionActionButton? actionButton;
AssetsHistorySectionTab(this.title, this.content);
const AssetsHistorySectionTab(this.title, this.content, this.actionButton);
}
class AssetsHistorySection extends StatefulWidget {
@@ -32,22 +47,40 @@ class _AssetsHistorySectionState extends State<AssetsHistorySection> {
void reloadTabs() {
final oldTabLength = tabs.length;
final hasAssetsTab = widget.dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled ||
(widget.dashboardViewModel.hasMweb && widget.dashboardViewModel.mwebEnabled);
final hasAssetsButton = widget.dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled;
final hasNftTab = isNFTACtivatedChain(widget.dashboardViewModel.wallet.type,
chainId: widget.dashboardViewModel.wallet.chainId);
tabs = [
if (widget.dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled ||
(widget.dashboardViewModel.hasMweb && widget.dashboardViewModel.mwebEnabled))
if (hasAssetsTab)
AssetsHistorySectionTab(
S.current.assets,
AssetsSection(
dashboardViewModel: widget.dashboardViewModel,
)),
),
hasAssetsButton ? AssetsHistorySectionActionButton(S.current.tokens, "assets/new-ui/options_slider.svg",
() {Navigator.of(context).pushNamed(
Routes.homeSettings,
arguments: widget.dashboardViewModel.balanceViewModel,
);
}): null),
AssetsHistorySectionTab(
S.current.history,
HistorySection(
detailsAsPage: false,
roundedTopSection: hasAssetsTab || hasNftTab,
dashboardViewModel: widget.dashboardViewModel,
)),
if (isNFTACtivatedChain(widget.dashboardViewModel.wallet.type,
chainId: widget.dashboardViewModel.wallet.chainId))
AssetsHistorySectionTab(S.current.nfts, NFTListingPage(nftViewModel: widget.nftViewModel))
short: true,
),
AssetsHistorySectionActionButton(
S.current.all_pascal_case, "assets/new-ui/arrow_right.svg", () {
openHistoryModal(context);
})),
if (hasNftTab)
AssetsHistorySectionTab(
S.current.nfts, NFTListingPage(nftViewModel: widget.nftViewModel), null)
];
if (oldTabLength != tabs.length) {
setState(() {
@@ -71,19 +104,39 @@ class _AssetsHistorySectionState extends State<AssetsHistorySection> {
Widget build(BuildContext context) {
return SliverMainAxisGroup(
slivers: [
// if(tabs.length>1)
AssetsTopBar(
dashboardViewModel: widget.dashboardViewModel,
tabs: tabs.map((item) => item.title).toList(),
onTabChange: (index) {
setState(() {
_selectedTab = index;
});
},
selectedTab: _selectedTab,
),
if (tabs.length > 1)
AssetsTopBar(
onTransactionHistoryOpened: () => openHistoryModal(context),
dashboardViewModel: widget.dashboardViewModel,
tabs: tabs,
onTabChange: (index) {
setState(() {
_selectedTab = index;
});
},
selectedTab: _selectedTab,
),
if (tabs.length == 1)
Observer(
builder: (_) => HistoryTopBar(
onTap: () => openHistoryModal(context),
roundedBottom: widget.dashboardViewModel.itemsShort.isEmpty,
),
),
tabs[_selectedTab].content
],
);
}
Future<void> openHistoryModal(BuildContext context) async {
await CupertinoScaffold.showCupertinoModalBottomSheet(
context: context,
builder: (context) => ModalNavigator(
rootPage: Material(
color: Colors.transparent,
child: HistoryModal(dashboardViewModel: widget.dashboardViewModel)),
parentContext: context,
));
widget.dashboardViewModel.changeAllFilterItems(true);
}
}
@@ -1,8 +1,5 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/assets_history_section.dart';
import 'package:hash_wallet/new-ui/widgets/line_tab_switcher.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/screens/dashboard/widgets/filter_widget.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:flutter/material.dart';
@@ -11,78 +8,99 @@ class AssetsTopBar extends StatelessWidget {
const AssetsTopBar({
super.key,
required this.onTabChange,
required this.onTransactionHistoryOpened,
required this.selectedTab,
required this.tabs,
required this.dashboardViewModel,
});
final void Function(int) onTabChange;
final VoidCallback onTransactionHistoryOpened;
final int selectedTab;
final List<String> tabs;
final List<AssetsHistorySectionTab> tabs;
final DashboardViewModel dashboardViewModel;
@override
Widget build(BuildContext context) {
final settingsButtonText = _getSettingsButtonText();
final hasTokenSettingsButton = settingsButtonText != null;
final actionButton = tabs[selectedTab].actionButton;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 32.0, bottom: 0.0, left: 12.0, right: 18.0),
padding: const EdgeInsets.only(
top: 20,
bottom: 0,
left: 12,
right: 18,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (tabs.length > 1)
LineTabSwitcher(
tabs: tabs,
onTabChange: onTabChange,
selectedTab: selectedTab,
)
else
SizedBox.shrink(),
Opacity(
opacity: hasTokenSettingsButton ? 1 : 0,
child: ElevatedButton(
onPressed: () {
if (tabs[selectedTab] == S.of(context).assets) {
Navigator.of(context).pushNamed(
Routes.homeSettings,
arguments: dashboardViewModel.balanceViewModel,
);
} else if (tabs[selectedTab] == S.of(context).history) {
showPopUp<void>(
context: context,
builder: (context) =>
FilterWidget(filterItems: dashboardViewModel.filterItems),
);
}
},
style: ElevatedButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(999999),
),
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainer,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
spacing: 6,
children: [
HashImageWidget(
imageUrl: "assets/new-ui/options_slider.svg",
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary, BlendMode.srcIn)),
Text(
settingsButtonText ?? "",
style: TextStyle(color: Theme.of(context).colorScheme.primary),
)
],
),
),
LineTabSwitcher(
tabs: tabs.map((item) => item.title).toList(),
onTabChange: onTabChange,
selectedTab: selectedTab,
),
AnimatedSwitcher(
duration: Duration(milliseconds: 300),
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.centerRight,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: Row(
key: ValueKey(selectedTab),
spacing: 8,
children: [
// Built conditionally rather than rendered at opacity 0: an
// invisible chip stayed focusable for screen readers. The
// SizedBox keeps the 40px header height the invisible chip
// used to occupy.
if (actionButton == null)
const SizedBox(height: 40)
else
MergeSemantics(
child: Semantics(
button: true,
child: GestureDetector(
key: const ValueKey("assets_history_action_button_key"),
onTap: actionButton.onPressed,
child: Container(
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999999),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
spacing: 6,
children: [
if (actionButton.title.isNotEmpty)
Text(
actionButton.title,
style:
TextStyle(color: Theme.of(context).colorScheme.primary),
),
ExcludeSemantics(
child: HashImageWidget(
imageUrl: actionButton.iconPath,
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn)),
),
],
),
),
),
),
),
),
],
),
),
],
@@ -90,16 +108,4 @@ class AssetsTopBar extends StatelessWidget {
),
);
}
String? _getSettingsButtonText() {
if (tabs[selectedTab] == S.current.assets &&
dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled) {
return S.current.tokens;
}
if (tabs[selectedTab] == S.current.history) {
return S.current.filters;
}
return null;
}
}
@@ -0,0 +1,125 @@
import 'package:hash_wallet/entities/new_ui_entities/list_item/list_Item_checkbox.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_swap_providers_page.dart';
import 'package:hash_wallet/new-ui/widgets/new_primary_button.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/new-ui/widgets/select_deselect_all.dart';
import 'package:hash_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/filter_item.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class HistoryFiltersPage extends StatelessWidget {
const HistoryFiltersPage({super.key, required this.dashboardViewModel});
final DashboardViewModel dashboardViewModel;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Container(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
ModalTopBar(
title: S.of(context).filters,
leadingIcon: Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
child: SingleChildScrollView(
controller: ModalScrollController.of(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Observer(
builder: (_) => Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Material(
color: Colors.transparent,
child: SelectDeselectAllBar(
title: S.of(context).type,
onSelected: dashboardViewModel.changeAllFilterItems),
),
),
NewListSections(
sections: {
"": dashboardViewModel.filterItems.map((item) {
if (item is SwapFilterItem) {
final String subtitle;
final Color subtitleColor;
if (dashboardViewModel.tradeFilterStore.displayAllTrades) {
subtitle = S.of(context).manage_providers;
subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant;
} else if (dashboardViewModel
.tradeFilterStore.enabledProvidersCount ==
0) {
subtitle = S.of(context).no_providers_selected;
subtitleColor = Color(0xFFFFB84E);
} else {
subtitle = "${item.enabledProviders()} ${S.of(context).providers}";
subtitleColor = Theme.of(context).colorScheme.primary;
}
return ListItemCheckbox(
onTap: () {
Navigator.of(context).push(CupertinoPageRoute(
builder: (context) => HistorySwapProvidersPage(
dashboardViewModel: dashboardViewModel)));
},
keyValue: item.caption,
label: S.of(context).swap,
value: item.value(),
onChanged: (val) {
if ((val &&
dashboardViewModel
.tradeFilterStore.enabledProvidersCount ==
0) ||
(!val &&
dashboardViewModel
.tradeFilterStore.enabledProvidersCount >
0)) {
dashboardViewModel.tradeFilterStore
.toggleDisplayExchange(ExchangeProviderDescription.all);
}
},
subtitle: subtitle,
subtitleColor: subtitleColor,
showArrow: true);
}
return ListItemCheckbox(
keyValue: item.caption,
label: item.caption,
value: item.value(),
onChanged: (val) => item.onChanged());
}).toList()
},
),
],
),
),
),
)),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(18.0),
child: NewPrimaryButton(
onPressed: () => dashboardViewModel.changeAllFilterItems(true),
text: S.of(context).reset_filters,
color: Theme.of(context).colorScheme.surfaceContainer,
textColor: Theme.of(context).colorScheme.primary),
))
],
),
),
);
}
}
@@ -0,0 +1,131 @@
import 'package:hash_wallet/core/csv_export_service.dart';
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_filters_page.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_section.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class HistoryModal extends StatelessWidget {
const HistoryModal({super.key, required this.dashboardViewModel});
final DashboardViewModel dashboardViewModel;
@override
Widget build(BuildContext context) {
return Container(
key: const ValueKey("history_modal_key"),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(18))),
child: Column(
children: [
ModalTopBar(
title: S.of(context).history,
leadingIcon: Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: Navigator.of(context).maybePop,
trailingIcon: HashImageWidget(
imageUrl: "assets/new-ui/tx_export.svg",
colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
trailingSemanticLabel: S.of(context).export_csv,
onTrailingPressed: () =>
getIt.get<CsvExportService>().exportToCsv(dashboardViewModel.items, context),
),
Expanded(
child: Stack(
children: [
Material(
child: CustomScrollView(physics: ClampingScrollPhysics(),
controller: ModalScrollController.of(context),
slivers: [
HistorySection(
detailsAsPage: true,
dashboardViewModel: dashboardViewModel,
short: false,
roundedTopSection: true)
])),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: IgnorePointer(
child: Material(
color: Colors.transparent,
child: Container(
height: MediaQuery.of(context).viewPadding.bottom + 168,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: <Color>[
Theme.of(context).colorScheme.surface.withAlpha(200),
Theme.of(context).colorScheme.surface.withAlpha(175),
Theme.of(context).colorScheme.surface.withAlpha(150),
Theme.of(context).colorScheme.surface.withAlpha(100),
Theme.of(context).colorScheme.surface.withAlpha(50),
Theme.of(context).colorScheme.surface.withAlpha(25),
Theme.of(context).colorScheme.surface.withAlpha(5),
],
),
),
),
),
),
),
Positioned(
left: 12,
right: 12,
bottom: MediaQuery.of(context).viewPadding.bottom + 24,
child: Material(
borderRadius: BorderRadius.circular(18),
color: Theme.of(context).colorScheme.surfaceContainer,
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () {
Navigator.of(context).push(CupertinoPageRoute(
builder: (context) =>
HistoryFiltersPage(dashboardViewModel: dashboardViewModel)));
},
child: Material(
color: Colors.transparent,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Theme.of(context).colorScheme.primary, width: 1)),
height: 56,
width: double.infinity,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 10,
children: [
HashImageWidget(
imageUrl: "assets/new-ui/filter_options.svg",
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
Text(
S.of(context).filters,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary),
)
],
),
),
),
),
))
],
))
],
),
);
}
}
@@ -1,4 +1,5 @@
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/entities/balance_display_mode.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/anonpay_history_tile.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_order_tile.dart';
@@ -7,7 +8,6 @@ import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_tra
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/payjoin_history_tile.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/utils/date_formatter.dart';
import 'package:hash_wallet/view_model/dashboard/anonpay_transaction_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/date_section_item.dart';
@@ -15,176 +15,223 @@ import 'package:hash_wallet/view_model/dashboard/order_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/payjoin_transaction_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/trade_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/transaction_list_item.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/sync_status.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:intl/intl.dart';
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
class HistorySection extends StatelessWidget {
const HistorySection({super.key, required this.dashboardViewModel});
const HistorySection(
{super.key,
required this.dashboardViewModel,
required this.short,
required this.roundedTopSection,
required this.detailsAsPage});
final DashboardViewModel dashboardViewModel;
final bool short;
final bool roundedTopSection;
final bool detailsAsPage;
/// A history row is a single button node: every text inside it (direction,
/// date, amounts) merges into one label.
Widget _historyRow({required VoidCallback onTap, required Widget child}) => MergeSemantics(
child: Semantics(
button: true,
child: GestureDetector(onTap: onTap, child: child),
),
);
@override
Widget build(BuildContext context) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
padding: EdgeInsets.only(left: 16.0, right: 16, top: short && roundedTopSection ? 18 : 0),
sliver: Observer(
builder: (_) => (dashboardViewModel.items.isEmpty)
? SliverPadding(
padding: EdgeInsets.only(top: 24),
sliver: SliverToBoxAdapter(
child: (dashboardViewModel.status is SyncingSyncStatus)
? SizedBox.shrink()
: Center(
child: Text(S.of(context).transactions_will_appear_here,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurfaceVariant)),
),
),
)
: SliverList(
delegate: SliverChildBuilderDelegate(
childCount: dashboardViewModel.items.length,
(context, index) => Observer(builder: (_) {
final prevItem = index == 0 ? null : dashboardViewModel.items[index - 1];
final topPadding = index == 0 ? 0.0 : 18.0;
final item = dashboardViewModel.items[index];
final nextItem = index == dashboardViewModel.items.length - 1
? null
: dashboardViewModel.items[index + 1];
builder: (_) {
final localeName = Localizations.localeOf(context).toString();
final items = short ? dashboardViewModel.itemsShort : dashboardViewModel.items;
final roundedBottom = (nextItem == null || nextItem is DateSectionItem);
final roundedTop = (prevItem == null || prevItem is DateSectionItem);
return (items.isEmpty)
? SliverPadding(
padding: EdgeInsets.only(top: 24),
sliver: SliverToBoxAdapter(
child: (dashboardViewModel.status is SyncingSyncStatus)
? SizedBox.shrink()
: Center(
child: Text(S.of(context).transactions_will_appear_here,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Theme.of(context).colorScheme.onSurfaceVariant)),
),
),
)
: SliverPadding(
padding: EdgeInsets.only(bottom: short ? 0 : 144),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
childCount: items.length,
(context, index) => Observer(builder: (_) {
final prevItem = index == 0 ? null : items[index - 1];
final topPadding = index == 0 ? 0.0 : 18.0;
final item = items[index];
final nextItem = index == items.length - 1 ? null : items[index + 1];
if (item is TransactionListItem) {
final transaction = item.transaction;
final transactionType = dashboardViewModel.getTransactionType(transaction);
final roundedBottom = (nextItem == null || nextItem is DateSectionItem);
final roundedTop = roundedTopSection &&
(prevItem == null || prevItem is DateSectionItem);
if (item.hasTokens && item.assetOfTransaction == null) {
return Container();
}
if (item is TransactionListItem) {
final transaction = item.transaction;
final transactionType =
dashboardViewModel.getTransactionType(transaction);
CryptoCurrency? asset;
if (transaction.additionalInfo["isLightning"] == true)
asset = CryptoCurrency.btcln;
else
asset = item.assetOfTransaction;
if (item.hasTokens && item.assetOfTransaction == null) {
return Container();
}
return GestureDetector(
onTap: () {
final page = getIt.get<TransactionDetailsModal>(param1: transaction);
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => page);
},
child: HistoryTile(
title: item.formattedTitle + transactionType,
date: DateFormat('HH:mm').format(transaction.date),
amount: item.formattedCryptoAmount,
amountFiat: item.formattedFiatAmount,
hasTokens: item.hasTokens,
chainIconPath: _getChainIconPath(),
roundedBottom: roundedBottom,
roundedTop: roundedTop,
bottomSeparator: !roundedBottom,
direction: item.transaction.direction,
pending: item.transaction.isPending,
asset: asset,
),
);
} else if (item is TradeListItem) {
final trade = item.trade;
final tradeFrom = trade.from;
final tradeTo = trade.to;
if (tradeFrom == null || tradeTo == null) {
return const SizedBox.shrink();
}
CryptoCurrency? asset;
if (transaction.additionalInfo["isLightning"] == true)
asset = CryptoCurrency.btcln;
else
asset = item.assetOfTransaction;
return GestureDetector(
onTap: () => Navigator.of(context)
.pushNamed(Routes.tradeDetails, arguments: trade),
child: HistoryTradeTile(
from: tradeFrom,
to: tradeTo,
provider: trade.provider,
date:
DateFormat('HH:mm').format(item.trade.createdAt ?? DateTime.now()),
amount: trade.amountFormatted(),
receiveAmount: trade.receiveAmountFormatted(),
roundedBottom: roundedBottom,
roundedTop: roundedTop,
bottomSeparator: !roundedBottom,
swapState: trade.state,
),
);
} else if (item is DateSectionItem) {
return Padding(
padding: EdgeInsets.only(left: 8.0, bottom: 8.0, top: topPadding),
child: Text(DateFormatter.convertDateTimeToReadableString(item.date),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant)));
} else if (item is OrderListItem) {
return GestureDetector(
onTap: () => Navigator.of(context)
.pushNamed(Routes.orderDetails, arguments: item.order),
child: HistoryOrderTile(
date: DateFormat('HH:mm').format(item.order.createdAt),
amount: item.orderFormattedAmount,
amountFiat: "USD 0.00",
roundedBottom: roundedBottom,
roundedTop: roundedTop,
bottomSeparator: !roundedBottom,
),
);
} else if (item is PayjoinTransactionListItem) {
final session = item.session;
return GestureDetector(
onTap: () => Navigator.of(context).pushNamed(
Routes.payjoinDetails,
arguments: [item.sessionId, item.transaction],
),
child: PayjoinHistoryTile(
createdAt: DateFormat('HH:mm').format(session.inProgressSince!),
amount: dashboardViewModel.appStore.amountParsingProxy
.getDisplayCryptoString(
session.amount.toInt(), CryptoCurrency.btc),
currency: item.transaction?.from ?? "BTC",
state: item.status,
isSending: session.isSenderSession,
roundedTop: roundedTop,
roundedBottom: roundedBottom,
bottomSeparator: !roundedBottom),
);
} else if (item is AnonpayTransactionListItem) {
final transactionInfo = item.transaction;
return GestureDetector(
onTap: () => Navigator.of(context)
.pushNamed(Routes.anonPayDetailsPage, arguments: transactionInfo),
child: AnonpayHistoryTile(
provider: transactionInfo.provider,
createdAt: DateFormat('HH:mm').format(transactionInfo.createdAt),
amount: transactionInfo.fiatAmount?.toString() ??
(transactionInfo.amountTo?.toString() ?? ''),
currency: transactionInfo.fiatAmount != null
? transactionInfo.fiatEquiv ?? ''
: CryptoCurrency.fromFullName(transactionInfo.coinTo)
.name
.toUpperCase(),
roundedTop: roundedTop,
return _historyRow(
onTap: () {
final page =
getIt.get<TransactionDetailsModal>(param1: transaction);
if (detailsAsPage) {
Navigator.of(context).push(CupertinoPageRoute(
builder: (context) => Material(child: page)));
} else {
showMaterialModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) =>
FractionallySizedBox(heightFactor: 0.9, child: page));
}
},
child: HistoryTile(
key: ValueKey("home_page_transaction_${transaction.id}_key"),
title: item.formattedTitle + transactionType,
date: _formatTransactionDate(item.date, localeName),
amount: item.formattedCryptoAmount,
amountFiat: item.formattedFiatAmount,
hasTokens: item.hasTokens,
chainIconPath: _getChainIconPath(),
roundedBottom: roundedBottom,
bottomSeparator: !roundedBottom));
} else
return Text(item.runtimeType.toString());
}),
),
),
roundedTop: roundedTop,
bottomSeparator: !roundedBottom,
direction: item.transaction.direction,
pending: item.transaction.isPending,
asset: asset,
),
);
} else if (item is TradeListItem) {
final trade = item.trade;
final tradeFrom = trade.from;
final tradeTo = trade.to;
return _historyRow(
onTap: () => Navigator.of(context)
.pushNamed(Routes.tradeDetails, arguments: trade),
child: HistoryTradeTile(
from: tradeFrom,
to: tradeTo,
provider: trade.provider,
date: _formatTransactionDate(
item.trade.createdAt ?? DateTime.now(), localeName),
amount: dashboardViewModel.balanceDisplayMode ==
BalanceDisplayMode.hiddenBalance
? "---"
: trade.amountFormatted(),
receiveAmount: dashboardViewModel.balanceDisplayMode ==
BalanceDisplayMode.hiddenBalance
? "---"
: trade.receiveAmountFormatted(),
roundedBottom: roundedBottom,
roundedTop: roundedTop,
bottomSeparator: !roundedBottom,
swapState: trade.state,
),
);
} else if (item is SpecificDateSectionItem) {
return Padding(
padding: EdgeInsets.only(left: 8.0, bottom: 8.0, top: topPadding),
child: Text(item.text,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant)));
} else if (item is DateSectionItem) {
return Padding(
padding: EdgeInsets.only(left: 8.0, bottom: 8.0, top: topPadding),
child: Text(DateFormat("MMMM yyyy", localeName).format(item.date),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant)));
} else if (item is OrderListItem) {
return _historyRow(
onTap: () => Navigator.of(context)
.pushNamed(Routes.orderDetails, arguments: item.order),
child: HistoryOrderTile(
date: _formatTransactionDate(item.order.createdAt, localeName),
amount: item.orderFormattedAmount,
amountFiat: "",
roundedBottom: roundedBottom,
roundedTop: roundedTop,
bottomSeparator: !roundedBottom,
),
);
} else if (item is PayjoinTransactionListItem) {
final session = item.session;
return _historyRow(
onTap: () => Navigator.of(context).pushNamed(
Routes.payjoinDetails,
arguments: [item.sessionId, item.transaction],
),
child: PayjoinHistoryTile(
createdAt:
_formatTransactionDate(session.inProgressSince!, localeName),
amount: dashboardViewModel.appStore.amountParsingProxy
.asDisplayString(Money(session.amount, CryptoCurrency.btc)),
currency: item.transaction?.from ?? "BTC",
state: item.status,
isSending: session.isSenderSession,
roundedTop: roundedTop,
roundedBottom: roundedBottom,
bottomSeparator: !roundedBottom),
);
} else if (item is AnonpayTransactionListItem) {
final transactionInfo = item.transaction;
return _historyRow(
onTap: () => Navigator.of(context).pushNamed(
Routes.anonPayDetailsPage,
arguments: transactionInfo),
child: AnonpayHistoryTile(
provider: transactionInfo.provider,
createdAt: _formatTransactionDate(
transactionInfo.createdAt, localeName),
amount: transactionInfo.fiatAmount?.toString() ??
(transactionInfo.amountTo?.toString() ?? ''),
currency: transactionInfo.fiatAmount != null
? transactionInfo.fiatEquiv ?? ''
: CryptoCurrency.fromFullName(transactionInfo.coinTo)
.name
.toUpperCase(),
roundedTop: roundedTop,
roundedBottom: roundedBottom,
bottomSeparator: !roundedBottom));
} else
return Text(item.runtimeType.toString());
}),
),
),
);
},
));
}
@@ -197,4 +244,36 @@ class HistorySection extends StatelessWidget {
return dashboardViewModel.wallet.currency.chainIconPath ?? "";
}
}
String _formatTransactionDate(DateTime date, String localeName) {
final time = DateFormat.Hm(localeName);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final thatDay = DateTime(date.year, date.month, date.day);
final daysAgo = today.difference(thatDay).inDays;
final timeStr = time.format(date);
if (daysAgo == 0) {
return timeStr;
}
if (daysAgo == 1) {
return "${S.current.yesterday}, $timeStr";
}
if (daysAgo < 7) {
final weekday = DateFormat.EEEE(localeName).format(date);
return "$weekday, $timeStr";
}
if (date.year == now.year) {
final dayMonth = DateFormat("d MMMM", localeName).format(date);
return "$dayMonth, $timeStr";
}
final full = DateFormat("d MMM yyyy", localeName).format(date);
return "$full, $timeStr";
}
}
@@ -0,0 +1,78 @@
import 'package:hash_wallet/entities/new_ui_entities/list_item/list_Item_checkbox.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/new-ui/widgets/select_deselect_all.dart';
import 'package:hash_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/filter_item.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class HistorySwapProvidersPage extends StatelessWidget {
const HistorySwapProvidersPage({super.key, required this.dashboardViewModel});
final DashboardViewModel dashboardViewModel;
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
ModalTopBar(
title: S.of(context).filter_swap_providers,
leadingIcon: Icon(Icons.arrow_back_ios_new),
leadingSemanticLabel: S.of(context).seed_alert_back,
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
child: SingleChildScrollView(
controller: ModalScrollController.of(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Observer(
builder: (_) => Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Material(
color: Colors.transparent,
child: SelectDeselectAllBar(
title: S.of(context).swap_providers,
onSelected: (val) {
if ((val &&
dashboardViewModel.tradeFilterStore.enabledProvidersCount ==
0) ||
(!val &&
dashboardViewModel.tradeFilterStore.enabledProvidersCount >
0)) {
dashboardViewModel.tradeFilterStore
.toggleDisplayExchange(ExchangeProviderDescription.all);
}
}),
),
),
NewListSections(sections: {
"": dashboardViewModel.exchangeFilterItems
.whereType<SwapProviderFilterItem>()
.map((item) => ListItemCheckbox(
iconPath: item.providerDescription.image,
keyValue: item.caption,
label: item.caption,
value: item.value(),
onChanged: (val) => dashboardViewModel.tradeFilterStore
.toggleDisplayExchange(item.providerDescription)))
.toList()
}),
],
),
),
),
))
],
),
);
}
}
@@ -1,4 +1,5 @@
import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_tile_base.dart';
import 'package:hash_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/transaction_direction.dart';
@@ -80,13 +81,13 @@ class HistoryTile extends StatelessWidget {
color: Theme.of(context).colorScheme.onInverseSurface, shape: BoxShape.circle),
child: HashImageWidget(
imageUrl: _getDirectionIconToken(),
height: 14,
width: 14,
colorFilter: ColorFilter.mode(
direction == TransactionDirection.outgoing
? Theme.of(context).colorScheme.inverseSurface.withAlpha(175)
: Colors.green,
BlendMode.srcIn),
width: 14,
height: 14,
),
),
)
@@ -99,10 +100,9 @@ class HistoryTile extends StatelessWidget {
children: [
Opacity(
opacity: pending ? 0.5 : 1,
child: HashImageWidget(
child: TokenImageWidget(
imageUrl: asset?.iconPath ?? "",
width: 34,
height: 34,
size: 34,
),
),
Align(
@@ -142,7 +142,8 @@ class HistoryTile extends StatelessWidget {
date: date,
amount: amount,
amountFiat: amountFiat,
leadingIcon: _getLeadingIcon(context),
// Decorative: `title` already reads out sent/received/pending.
leadingIcon: ExcludeSemantics(child: _getLeadingIcon(context)),
primaryTextColor: _getPrimaryTextColor(),
roundedTop: roundedTop,
roundedBottom: roundedBottom,
@@ -87,7 +87,7 @@ class HistoryTileBase extends StatelessWidget {
bottomLeft: Radius.circular(roundedBottom ? 22 : 0),
bottomRight: Radius.circular(roundedBottom ? 22 : 0),
)),
color: Theme.of(context).colorScheme.onInverseSurface,
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Padding(
padding: const EdgeInsets.symmetric(
@@ -153,7 +153,7 @@ class HistoryTileBase extends StatelessWidget {
),
if (bottomSeparator)
Container(
color: Theme.of(context).colorScheme.onInverseSurface,
color: Theme.of(context).colorScheme.surfaceContainer,
child: Padding(
padding: EdgeInsets.only(left: 56, right: 16),
child: Container(
@@ -0,0 +1,69 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:flutter/material.dart';
class HistoryTopBar extends StatelessWidget {
const HistoryTopBar({super.key, required this.onTap, required this.roundedBottom});
final VoidCallback onTap;
final bool roundedBottom;
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Semantics(
button: true,
label: S.of(context).history,
onTap: onTap,
child: ExcludeSemantics(
child: GestureDetector(
key: const ValueKey("history_top_bar_key"),
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 20),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.vertical(
top: Radius.circular(18),
bottom: roundedBottom ? Radius.circular(18) : Radius.zero),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: EdgeInsets.symmetric(
vertical: 4,
horizontal: 12,
),
child: Column(
spacing: 12,
children: [
SizedBox.shrink(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(S.of(context).history),
HashImageWidget(
imageUrl: "assets/new-ui/arrow_right.svg",
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
)
],
),
if (!roundedBottom)
Container(
height: 1,
width: double.infinity,
color: Theme.of(context).colorScheme.outlineVariant.withAlpha(175),
)
else
Container(height: 2)
],
),
),
),
),
),
),
),
);
}
}
@@ -15,13 +15,13 @@ class HistoryTradeTile extends StatelessWidget {
required this.roundedTop,
required this.roundedBottom,
required this.bottomSeparator,
required this.from,
required this.to,
this.from,
this.to,
required this.swapState,
required this.provider});
final CryptoCurrency from;
final CryptoCurrency to;
final CryptoCurrency? from;
final CryptoCurrency? to;
final ExchangeProviderDescription provider;
final String date;
final String amount;
@@ -32,6 +32,10 @@ class HistoryTradeTile extends StatelessWidget {
final TradeState swapState;
Widget _getLeadingStack(BuildContext context) {
if (from == null || to == null) {
return SizedBox(width: 50, height: 50);
}
double currencyIconSize = 22.0;
return SizedBox(
@@ -40,7 +44,7 @@ class HistoryTradeTile extends StatelessWidget {
child: Stack(
children: [
HashImageWidget(
imageUrl: _getIconPath(from), width: currencyIconSize, height: currencyIconSize),
imageUrl: _getIconPath(from!), width: currencyIconSize, height: currencyIconSize),
Positioned(
top: currencyIconSize / 2,
left: currencyIconSize / 2,
@@ -50,7 +54,7 @@ class HistoryTradeTile extends StatelessWidget {
Border.all(width: 2, color: Theme.of(context).colorScheme.surfaceContainer),
shape: BoxShape.circle),
child: HashImageWidget(
imageUrl: _getIconPath(to),
imageUrl: _getIconPath(to!),
width: currencyIconSize,
height: currencyIconSize,
),
@@ -85,8 +89,10 @@ class HistoryTradeTile extends StatelessWidget {
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500)),
Text(from.title, style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
if (fromChainIcon.isNotEmpty)
if (from?.title != null)
Text(from!.title,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
if (fromChainIcon?.isNotEmpty ?? false)
HashImageWidget(
imageUrl: fromChainIcon,
width: 12,
@@ -106,10 +112,17 @@ class HistoryTradeTile extends StatelessWidget {
receiveAmount.withMaxDecimals(8),
style: TextStyle(fontWeight: FontWeight.w500),
),
Text(
to.title,
),
if (toChainIcon.isNotEmpty) HashImageWidget(imageUrl: toChainIcon, width: 12, height: 12)
if (to != null)
Text(
to!.title,
),
if (toChainIcon?.isNotEmpty ?? false)
HashImageWidget(
imageUrl: toChainIcon,
width: 12,
height: 12,
color: Theme.of(context).colorScheme.onSurface,
)
],
),
leadingIcon: _getLeadingStack(context),
@@ -137,7 +150,8 @@ class HistoryTradeTile extends StatelessWidget {
return "";
}
String _getChainIcon(CryptoCurrency currency) {
String? _getChainIcon(CryptoCurrency? currency) {
if (currency == null) return null;
try {
if (currency.title.isNotEmpty) {
final parsedCurrency =
@@ -1,23 +1,31 @@
import 'package:hash_wallet/entities/new_ui_entities/list_item/list_item.dart';
import 'package:hash_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/screens/transaction_details/confirmations_list_item.dart';
import 'package:hash_wallet/src/screens/transaction_details/transaction_details_list_item.dart';
import 'package:hash_wallet/src/screens/transaction_details/address_list_item.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:hash_wallet/utils/address_formatter.dart';
import 'package:hash_wallet/view_model/transaction_details_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item.dart";
import "package:hash_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/coins_page/token_image_widget.dart";
import "package:hash_wallet/new-ui/widgets/copy_wrapper.dart";
import "package:hash_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:hash_wallet/routes.dart";
import "package:hash_wallet/src/screens/transaction_details/address_list_item.dart";
import "package:hash_wallet/src/screens/transaction_details/confirmations_list_item.dart";
import "package:hash_wallet/src/screens/transaction_details/transaction_details_list_item.dart";
import "package:hash_wallet/src/widgets/new_list_row/new_list_section.dart";
import "package:hash_wallet/utils/address_formatter.dart";
import "package:hash_wallet/view_model/transaction_details_view_model.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_mobx/flutter_mobx.dart";
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
class TransactionDetailsModal extends StatefulWidget {
const TransactionDetailsModal({super.key, required this.transactionDetailsViewModel});
const TransactionDetailsModal(
{
required this.transactionDetailsViewModel,
this.highlightNoteField = false,
super.key,
});
final TransactionDetailsViewModel transactionDetailsViewModel;
final bool highlightNoteField;
@override
State<TransactionDetailsModal> createState() => _TransactionDetailsModalState();
@@ -37,191 +45,237 @@ class _TransactionDetailsModalState extends State<TransactionDetailsModal> {
widget.transactionDetailsViewModel.updateNote(noteController.text);
}
});
if (widget.highlightNoteField) {
noteFocusNode.requestFocus();
}
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.9,
minChildSize: 0.25,
maxChildSize: 0.9,
snap: true,
snapSizes: const [0.9],
builder: (context, controller) => SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(25))),
child: Column(
children: [
ModalTopBar(
title: S.of(context).transaction,
leadingIcon: Icon(Icons.close),
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
child: SingleChildScrollView(
controller: controller,
child: Column(
children: [
HashImageWidget(
imageUrl: widget.transactionDetailsViewModel.transactionAsset
.iconPath ??
"",
width: 64,
height: 64),
SizedBox(height: 10),
Text(
widget.transactionDetailsViewModel.formattedTitle +
widget.transactionDetailsViewModel.formattedStatus,
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
),
Text(
widget.transactionDetailsViewModel.formattedCryptoAmount,
style: TextStyle(fontSize: 28),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
spacing: 12,
children: [
NewListSections(sections: {
"": widget.transactionDetailsViewModel.items
.map((item) {
if (item.value.isEmpty) return null;
final shouldBuildBottomWidget =
item.value.length > 25;
return ListItemRegularRow(
copyableText: item.value,
showArrow: false,
keyValue:
((item.key as ValueKey?)?.value as String?) ??
item.title,
label: item.title,
trailingWidget: shouldBuildBottomWidget
? null
: _buildTrailingWIdget(item),
bottomWidget: shouldBuildBottomWidget
? _buildBottomWidget(item)
: null);
})
.whereType<ListItem>()
.toList(),
}),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context).note),
TextField(
focusNode: noteFocusNode,
controller: noteController,
decoration: InputDecoration(
hintText: S.of(context).add_a_note,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true),
)
],
),
),
),
Observer(
builder: (_) => NewListSections(sections: {
"view tx": [
ListItemRegularRow(
keyValue: "view tx on",
label: widget.transactionDetailsViewModel
.explorerDescription,
onTap: widget
.transactionDetailsViewModel.launchExplorer,
foregroundColor:
Theme.of(context).colorScheme.primary,
trailingIconPath: "assets/new-ui/link_arrow.svg",
trailingIconSize: 8)
],
if (widget.transactionDetailsViewModel.canReplaceByFee)
"rbf": [
ListItemRegularRow(
keyValue: "replace by fee",
label: S.of(context).bump_fee,
onTap: () {
Navigator.of(context)
.pushNamed(Routes.bumpFeePage, arguments: [
widget.transactionDetailsViewModel
.transactionInfo,
widget.transactionDetailsViewModel
.rawTransaction
]);
})
]
}),
)
],
Widget build(BuildContext context) => SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(25)),
),
child: Column(
children: [
ModalTopBar(
title: S.of(context).transaction,
leadingIcon: const Icon(Icons.close),
leadingSemanticLabel: S.of(context).close,
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
child: SingleChildScrollView(
controller: ModalScrollController.of(context),
child: Column(
children: [
TokenImageWidget(
imageUrl:
widget.transactionDetailsViewModel.transactionAsset.iconPath ?? "",
size: 64,
),
const SizedBox(height: 10),
Text(
widget.transactionDetailsViewModel.formattedTitle +
widget.transactionDetailsViewModel.formattedStatus,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
),
Observer(
builder: (_) => CopyWrapper(
requireLongPress: true,
data: ClipboardData(
text: widget.transactionDetailsViewModel.transactionCopyAmount,
),
builder: (context, copied) => AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(
key: ValueKey(copied),
copied
? S.of(context).copied
: widget.transactionDetailsViewModel.transactionAmount,
style: TextStyle(
fontSize: 28,
color: copied
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
spacing: 12,
children: [
NewListSections(
sections: {
"": widget.transactionDetailsViewModel.items
.map((item) {
if (item.value.isEmpty) {
return null;
}
final shouldBuildBottomWidget = item.value.length > 25;
return ListItemRegularRow(
copyableText: item.value,
showArrow: false,
keyValue: ((item.key as ValueKey?)?.value as String?) ??
item.title,
label: item.title,
trailingWidget: shouldBuildBottomWidget
? null
: _buildTrailingWidget(item),
bottomWidget: shouldBuildBottomWidget
? _buildBottomWidget(item)
: null,
);
})
.whereType<ListItem>()
.toList(),
},
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.surfaceContainer,
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context).note),
TextField(
focusNode: noteFocusNode,
controller: noteController,
decoration: InputDecoration(
hintText: S.of(context).add_a_note,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
),
),
],
),
),
),
Observer(
builder: (_) => NewListSections(
sections: {
"view tx": [
ListItemRegularRow(
keyValue: "view tx on",
label: widget
.transactionDetailsViewModel.explorerDescription,
onTap: widget.transactionDetailsViewModel.launchExplorer,
foregroundColor: Theme.of(context).colorScheme.primary,
trailingIconPath: "assets/new-ui/link_arrow.svg",
trailingIconSize: 8,
),
],
if (widget.transactionDetailsViewModel.canReplaceByFee)
"rbf": [
ListItemRegularRow(
keyValue: "replace by fee",
label: S.of(context).bump_fee,
onTap: () {
Navigator.of(context).pushNamed(
Routes.bumpFeePage,
arguments: [
widget
.transactionDetailsViewModel.transactionInfo,
widget.transactionDetailsViewModel.rawTransaction,
],
);
},
),
],
},
),
),
SizedBox(height: MediaQuery.of(context).viewPadding.bottom)
],
),
),
)
],
SizedBox(height: MediaQuery.of(context).viewPadding.bottom),
],
),
),
),
),
],
),
));
}
Widget _buildTrailingWIdget(TransactionDetailsListItem item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: switch (item.runtimeType) {
ConfirmationsListItem => Row(
children: [
Text((item as ConfirmationsListItem).current.toString(),
style: TextStyle(color: Theme.of(context).colorScheme.primary)),
if (item.needed > 0)
Text("/${item.needed}",
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant))
],
),
),
_ => Text(
item.value,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
)
},
);
}
),
);
Widget _buildTrailingWidget(TransactionDetailsListItem item) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: switch (item.runtimeType) {
ConfirmationsListItem => Row(
children: [
Text(
(item as ConfirmationsListItem).current.toString(),
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
if (item.needed > 0)
Text(
"/${item.needed}",
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
),
_ => Text(
item.value,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
)
},
);
Widget _buildBottomWidget(TransactionDetailsListItem item) {
return switch (item.runtimeType) {
AddressListItem => AddressFormatter.buildSegmentedAddress(
address: item.value,
evenTextStyle: TextStyle(
fontSize: 12,
fontFamily: "IBM Plex Mono",
color: Theme.of(context).colorScheme.onSurface)),
AddressListItem => _segmentedAddressList(item.value),
_ => Text(
item.value,
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant),
)
};
}
Widget _segmentedAddressList(String value) {
final style = TextStyle(
fontSize: 12,
fontFamily: "IBM Plex Mono",
color: Theme.of(context).colorScheme.onSurface,
);
final lines = value
.split(RegExp(r'[,\n]'))
.map((final line) => line.trim())
.where((final line) => line.isNotEmpty)
.toList();
if (lines.length <= 1) {
return AddressFormatter.buildSegmentedAddress(
address: lines.isNotEmpty ? lines.first : value.trim(),
evenTextStyle: style,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final line in lines)
AddressFormatter.buildSegmentedAddress(address: line, evenTextStyle: style),
],
);
}
}
@@ -1,8 +1,9 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:cw_core/card_design.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/new-ui/widgets/money/money_text.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:cw_core/amount/money.dart";
import "package:cw_core/card_design.dart";
import "package:flutter/material.dart";
class BalanceCardAction {
final String label;
@@ -23,7 +24,7 @@ class BalanceCard extends StatelessWidget {
this.borderRadius = 20,
this.selected = false,
this.accountName = "",
this.accountBalance = "",
this.accountBalance,
this.balance = "",
this.fiatBalance = "",
this.assetName = "",
@@ -38,7 +39,7 @@ class BalanceCard extends StatelessWidget {
final double width;
final double borderRadius;
final Gradient? gradient;
final String accountBalance;
final Money? accountBalance;
final String accountName;
final String balance;
final String fiatBalance;
@@ -63,7 +64,7 @@ class BalanceCard extends StatelessWidget {
final leadText = fiatFirst ? S.of(context).wallet_balance : accountName;
final bool showText = accountBalance.isNotEmpty ||
final bool showText = accountBalance != null ||
leadText.isNotEmpty ||
balance.isNotEmpty ||
fiatBalance.isNotEmpty ||
@@ -92,15 +93,18 @@ class BalanceCard extends StatelessWidget {
? ClipRSuperellipse(
borderRadius: BorderRadius.circular(borderRadius),
key: ValueKey(design.imagePath),
child: HashImageWidget(
imageUrl: design.imagePath,
width: width,
height: height,
fit: BoxFit.fill,
// Purely decorative card artwork.
child: ExcludeSemantics(
child: HashImageWidget(
imageUrl: design.imagePath,
width: width,
height: height,
fit: BoxFit.fill,
),
),
)
: const SizedBox.shrink(
key: ValueKey('svgFullOff'),
key: ValueKey("svgFullOff"),
),
),
Padding(
@@ -115,7 +119,7 @@ class BalanceCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: [
if (leadText.isNotEmpty || accountBalance.isNotEmpty)
if (leadText.isNotEmpty || accountBalance != null)
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -147,9 +151,16 @@ class BalanceCard extends StatelessWidget {
AnimatedOpacity(
opacity: selected ? 0 : 1,
duration: textFadeDuration,
child: Text(
accountBalance,
style: TextStyle(color: design.colors.textColor, fontSize: 14),
// Opacity alone keeps the text readable by screen
// readers, so drop it while it is invisible.
child: ExcludeSemantics(
excluding: selected,
child: MoneyText.optional(
accountBalance,
fractionalDigits: accountBalance?.decimals ?? 8,
showSymbol: false,
style: TextStyle(color: design.colors.textColor, fontSize: 14),
),
),
),
],
@@ -157,40 +168,51 @@ class BalanceCard extends StatelessWidget {
AnimatedOpacity(
opacity: selected ? 1 : 0,
duration: textFadeDuration,
child: AnimatedSwitcher(
duration: designSwitchDuration,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.centerLeft,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
// Only the selected card's balance is visible, so only it
// may be announced.
child: ExcludeSemantics(
excluding: !selected,
child: AnimatedSwitcher(
duration: designSwitchDuration,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.centerLeft,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: Row(
key: ValueKey("$balance ${resolvedAssetName.toUpperCase()}"),
spacing: 8.0,
children: [
AnimatedDefaultTextStyle(
duration: designSwitchDuration,
style: DefaultTextStyle.of(context).style.copyWith(
color: design.colors.textColor,
fontSize: 28,
fontWeight: FontWeight.w500,
letterSpacing: -0.4),
child: !fiatFirst && balance.isEmpty
? MoneyText.optional(
accountBalance,
fractionalDigits: accountBalance?.decimals ?? 8,
showSymbol: false,
)
: Text(fiatFirst ? fiatBalance : balance),
),
AnimatedDefaultTextStyle(
duration: designSwitchDuration,
style: DefaultTextStyle.of(context).style.copyWith(
color: design.colors.textColorSecondary,
fontSize: 28,
fontWeight: FontWeight.w400,
letterSpacing: -0.4),
child: Text(resolvedAssetName),
),
],
);
},
child: Row(
key: ValueKey("$balance ${resolvedAssetName.toUpperCase()}"),
spacing: 8.0,
children: [
AnimatedDefaultTextStyle(
duration: designSwitchDuration,
style: DefaultTextStyle.of(context).style.copyWith(
color: design.colors.textColor,
fontSize: 28,
fontWeight: FontWeight.w500,
letterSpacing: -0.4),
child: Text(fiatFirst ? fiatBalance : balance),
),
AnimatedDefaultTextStyle(
duration: designSwitchDuration,
style: DefaultTextStyle.of(context).style.copyWith(
color: design.colors.textColorSecondary,
fontSize: 28,
fontWeight: FontWeight.w400,
letterSpacing: -0.4),
child: Text(resolvedAssetName),
),
],
),
),
),
),
@@ -203,17 +225,19 @@ class BalanceCard extends StatelessWidget {
child: AnimatedSwitcher(
duration: designSwitchDuration,
layoutBuilder: (currentChild, previousChildren) {
final currentKey = currentChild?.key;
return Stack(
alignment: Alignment.centerLeft,
children: <Widget>[
...previousChildren,
for (final child in previousChildren)
if (child.key != currentKey) child,
if (currentChild != null) currentChild,
],
);
},
child: Text(
key: ValueKey(fiatFirst ? balance : fiatBalance),
fiatFirst ? "$assetName $balance" : "$fiatCurrencyTitle $fiatBalance",
fiatFirst ? "$assetName $balance" : fiatBalance,
),
),
),
@@ -251,18 +275,12 @@ class BalanceCard extends StatelessWidget {
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
child: design.backgroundType == CardDesignBackgroundTypes.svgIcon
? HashImageWidget(
imageUrl: design.imagePath,
key: const ValueKey('svgIcon'),
height: iconWidth,
width: iconWidth,
colorFilter: ColorFilter.mode(
design.colors.backgroundImageColor.withAlpha(80),
BlendMode.srcIn,
),
// Purely decorative card artwork.
? ExcludeSemantics(
child: _CornerSvgIcon(design: design, iconWidth: iconWidth),
)
: const SizedBox.shrink(
key: ValueKey('svgIconOff'),
key: ValueKey("svgIconOff"),
),
),
],
@@ -276,18 +294,28 @@ class BalanceCard extends StatelessWidget {
child: AnimatedOpacity(
duration: designSwitchDuration,
opacity: onCustomizeTapped == null ? 0 : 1,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onCustomizeTapped,
child: Container(
height: 40,
width: 40,
child: Center(
child: HashImageWidget(
imageUrl: "assets/new-ui/3dots_vertical.svg",
alignment: Alignment.topRight,
colorFilter:
ColorFilter.mode(design.colors.textColorSecondary, BlendMode.srcIn),
// Faded out and inert: must not be a focusable phantom control.
child: ExcludeSemantics(
excluding: onCustomizeTapped == null,
child: Semantics(
button: true,
label: S.of(context).wallet_menu,
onTap: onCustomizeTapped,
child: GestureDetector(
excludeFromSemantics: true,
behavior: HitTestBehavior.opaque,
onTap: onCustomizeTapped,
child: Container(
height: 40,
width: 40,
child: Center(
child: HashImageWidget(
imageUrl: "assets/new-ui/3dots_vertical.svg",
alignment: Alignment.topRight,
colorFilter:
ColorFilter.mode(design.colors.textColorSecondary, BlendMode.srcIn),
),
),
),
),
),
@@ -299,28 +327,58 @@ class BalanceCard extends StatelessWidget {
);
}
Widget getBalanceCardActionButton(BalanceCardAction action) => GestureDetector(
Widget getBalanceCardActionButton(BalanceCardAction action) => Semantics(
button: true,
label: action.label,
onTap: action.onTap,
child: Container(
decoration: BoxDecoration(
color: design.colors.backgroundImageColor.withAlpha(75),
borderRadius: BorderRadius.circular(10000000),
),
margin: const EdgeInsets.only(right: 10),
padding: const EdgeInsets.only(left: 10, right: 5, top: 5, bottom: 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(right: 6),
child: Text(
action.label,
style: TextStyle(color: design.colors.textColor, fontSize: 16),
),
child: ExcludeSemantics(
child: GestureDetector(
onTap: action.onTap,
child: Container(
decoration: BoxDecoration(
color: design.colors.backgroundImageColor.withAlpha(75),
borderRadius: BorderRadius.circular(10000000),
),
Icon(action.icon, color: design.colors.textColorSecondary, size: action.iconSize),
],
margin: const EdgeInsets.only(right: 10),
padding: const EdgeInsets.only(left: 10, right: 5, top: 5, bottom: 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(right: 6),
child: Text(
action.label,
style: TextStyle(color: design.colors.textColor, fontSize: 16),
),
),
Icon(action.icon, color: design.colors.textColorSecondary, size: action.iconSize),
],
),
),
),
),
);
}
class _CornerSvgIcon extends StatelessWidget {
const _CornerSvgIcon({required this.design, required this.iconWidth});
final CardDesign design;
final double iconWidth;
@override
Widget build(BuildContext context) {
return HashImageWidget(
imageUrl: design.imagePath,
key: ValueKey(design.imagePath),
height: iconWidth,
width: iconWidth,
colorFilter: design.preColoredIcon
? null
: ColorFilter.mode(
design.colors.backgroundImageColor.withValues(alpha: 0.33),
BlendMode.dstIn,
),
);
}
}
@@ -1,4 +1,6 @@
import 'dart:math';
import 'package:hash_wallet/core/utilities.dart';
import 'package:cw_core/balance_card_layout.dart';
import 'package:hash_wallet/bitcoin/bitcoin.dart';
import 'package:hash_wallet/di.dart';
@@ -8,11 +10,12 @@ import 'package:hash_wallet/entities/provider_types.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/modal_navigator.dart';
import 'package:hash_wallet/new-ui/pages/send_page.dart';
import 'package:hash_wallet/new-ui/widgets/buy_sell/buy_sell_selector_modal.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/utils/feature_flag.dart';
import 'package:hash_wallet/utils/payment_request.dart';
import 'package:hash_wallet/utils/responsive_layout_util.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/monero_account_list/monero_account_list_view_model.dart';
import 'package:hash_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart';
import 'package:cw_core/card_design.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/unspent_coin_type.dart';
@@ -29,14 +32,14 @@ import 'balance_card.dart';
class CardsView extends StatefulWidget {
const CardsView(
{super.key,
required this.dashboardViewModel,
required this.accountListViewModel,
required this.lightningMode,
required this.onCompactModeBackgroundCardsTapped,
required this.onCustomizeTapped});
required this.dashboardViewModel,
required this.accountListViewModel,
required this.lightningMode,
required this.onCompactModeBackgroundCardsTapped,
required this.onCustomizeTapped});
final DashboardViewModel dashboardViewModel;
final MoneroAccountListViewModel? accountListViewModel;
final WalletAccountListViewModel? accountListViewModel;
final VoidCallback onCompactModeBackgroundCardsTapped;
final VoidCallback onCustomizeTapped;
final bool lightningMode;
@@ -48,22 +51,39 @@ class CardsView extends StatefulWidget {
class _CardsViewState extends State<CardsView> {
late int _selectedIndex;
bool isFirstBuild = true;
ReactionDisposer? _cardOrderReaction;
int get _fallbackSelectedIndex =>
max(0, widget.dashboardViewModel.cardOrder.length - 1);
@override
void initState() {
super.initState();
_selectedIndex = widget.dashboardViewModel.cardOrder.length - 1;
reaction(
(_) => widget.dashboardViewModel.cardOrder.values.toList(),
(_) => setState(() {
_selectedIndex = widget.dashboardViewModel.cardOrder.length - 1;
}));
_selectedIndex = _fallbackSelectedIndex;
_cardOrderReaction = reaction(
(_) => widget.dashboardViewModel.cardOrder.values.toList(),
(_) {
if (mounted) setState(() => _selectedIndex = _fallbackSelectedIndex);
},
);
}
@override
void dispose() {
_cardOrderReaction?.call();
super.dispose();
}
static const Duration animDuration = Duration(milliseconds: 200);
static const int compactModeTreshold = 4;
static const int maxCards = 5;
/// No account name on the card: Lightning card, or Bitcoin with multi-accounts off.
bool get _hideAccountInfo =>
widget.lightningMode ||
(widget.dashboardViewModel.wallet.type == WalletType.bitcoin &&
!widget.dashboardViewModel.isMultiAccountsEnabled);
Widget _buildCard(int visualIndex, int realIndex, int numCards, double parentWidth,
Map<int, int> order, bool compactMode, double overlapAmount) {
final baseTop = overlapAmount * (numCards - 1);
@@ -74,11 +94,32 @@ class _CardsViewState extends State<CardsView> {
final top = baseTop - (howFarBehind * overlapAmount);
final double cardWidth = MediaQuery.of(context).size.width * 0.878;
final double effectiveCardWidth = min(cardWidth, 768);
final left = (parentWidth - effectiveCardWidth) / 2.0;
final isSelected = _selectedIndex == visualIndex;
final accounts = widget.accountListViewModel?.accounts;
final account = accounts?.firstWhereOrNull((account) => account.id == realIndex);
final cardLabel = !_hideAccountInfo && account != null ? account.label : S.of(context).balance;
Future<void> onCardTap() async {
// printV(visualIndex);
if (compactMode && visualIndex != 0) {
widget.onCompactModeBackgroundCardsTapped();
} else if (!compactMode) {
if (account != null && !_hideAccountInfo) {
await widget.accountListViewModel!.select(account);
}
if (mounted) setState(() => _selectedIndex = visualIndex);
}
}
void onCardLongPress() {
if (_selectedIndex == visualIndex) {
widget.dashboardViewModel.balanceViewModel.switchBalanceValue();
}
HapticFeedback.heavyImpact();
}
return AnimatedPositioned(
key: ValueKey("$visualIndex $realIndex"),
duration: animDuration,
@@ -89,125 +130,117 @@ class _CardsViewState extends State<CardsView> {
duration: animDuration,
curve: Curves.easeOut,
scale: scale,
child: GestureDetector(
onTap: () {
// printV(visualIndex);
if (compactMode && visualIndex != 0) {
widget.onCompactModeBackgroundCardsTapped();
} else if (!compactMode) {
setState(() {
if (widget.accountListViewModel != null)
widget.accountListViewModel!
.select(widget.accountListViewModel!.accounts[realIndex]);
_selectedIndex = visualIndex;
});
}
},
onLongPress: () {
if (_selectedIndex == visualIndex) {
widget.dashboardViewModel.balanceViewModel.switchBalanceValue();
}
;
HapticFeedback.heavyImpact();
},
child: Observer(builder: (_) {
if (realIndex >= (widget.accountListViewModel?.accounts.length ?? 1)) {
return Container();
}
final account = widget.accountListViewModel?.accounts[realIndex];
// The card is the tap target; the balances and the card's own buttons stay
// reachable as children of this node.
child: Semantics(
button: true,
selected: isSelected,
label: cardLabel,
hint: isSelected
? (widget.dashboardViewModel.balanceViewModel.displayMode ==
BalanceDisplayMode.hiddenBalance
? S.of(context).long_press_show_balance
: S.of(context).long_press_hide_balance)
: null,
onTap: onCardTap,
onLongPress: isSelected ? onCardLongPress : null,
child: GestureDetector(
excludeFromSemantics: true,
onTap: onCardTap,
onLongPress: onCardLongPress,
child: Observer(builder: (_) {
final account = widget.accountListViewModel?.accounts
.firstWhereOrNull((account) => account.id == realIndex);
// The second balance should always be the lightning balance
// printV(widget.dashboardViewModel.balanceViewModel.formattedBalances.first.availableBalance);
final walletBalanceRecord = widget.dashboardViewModel.balanceViewModel
.getMainBalanceRecord(widget.lightningMode);
// The second balance should always be the lightning balance
// printV(widget.dashboardViewModel.balanceViewModel.formattedBalances.first.availableBalance);
final walletBalanceRecord = widget.dashboardViewModel.balanceViewModel
.getMainBalanceRecord(widget.lightningMode);
late final String walletBalance;
late final String walletFiatBalance;
if (widget.dashboardViewModel.mwebEnabled && widget.dashboardViewModel.hasMweb) {
if (widget.dashboardViewModel.balanceViewModel.displayMode ==
BalanceDisplayMode.hiddenBalance) {
walletBalance = '●●●●●●';
walletFiatBalance = '●●●●●●';
late final String walletBalance;
late final String walletFiatBalance;
if (widget.dashboardViewModel.mwebEnabled && widget.dashboardViewModel.hasMweb) {
if (widget.dashboardViewModel.balanceViewModel.displayMode ==
BalanceDisplayMode.hiddenBalance) {
walletBalance = '●●●●●●';
walletFiatBalance = '●●●●●●';
} else {
walletBalance = walletBalanceRecord?.combinedAvailableBalance ?? "0";
walletFiatBalance = walletBalanceRecord?.combinedFiatAvailableBalance ?? "0.00";
}
} else if (widget.dashboardViewModel.balanceViewModel.showCombinedBalance) {
walletBalance = "";
walletFiatBalance = widget.dashboardViewModel.balanceViewModel.combinedFiatBalance;
} else {
walletBalance = walletBalanceRecord?.combinedAvailableBalance ?? "0";
walletFiatBalance = walletBalanceRecord?.combinedFiatAvailableBalance ?? "0.00";
walletBalance = walletBalanceRecord?.availableBalance ?? "0";
walletFiatBalance = walletBalanceRecord?.fiatAvailableBalance ?? "0.00";
}
} else if (widget.dashboardViewModel.balanceViewModel.showCombinedBalance) {
walletBalance = "";
walletFiatBalance = widget.dashboardViewModel.balanceViewModel.combinedFiatBalance;
} else {
walletBalance = walletBalanceRecord?.availableBalance ?? "0";
walletFiatBalance = walletBalanceRecord?.fiatAvailableBalanceRaw ?? "0.00";
}
// the card designs is empty if widget gets built before it loads.
// should get populated before user sees anything
final CardDesign cardDesign;
if (widget.dashboardViewModel.cardDesigns.isEmpty ||
realIndex >= widget.dashboardViewModel.cardDesigns.length)
cardDesign = CardDesign.genericDefault;
else if (widget.lightningMode)
cardDesign = widget.dashboardViewModel.cardDesigns[realIndex + 1];
else
cardDesign = widget.dashboardViewModel.cardDesigns[realIndex];
// the card designs is empty if widget gets built before it loads.
// should get populated before user sees anything
final designIndex = widget.dashboardViewModel.cardAccountIndices.indexOf(
widget.lightningMode ? -2 : realIndex,
);
final cardDesign = designIndex < 0 ||
designIndex >= widget.dashboardViewModel.cardDesigns.length
? CardDesign.genericDefault
: widget.dashboardViewModel.cardDesigns[designIndex];
final String accountName;
final String accountBalance;
if (account == null) {
accountName = "";
accountBalance = "";
} else {
accountName = account.label;
accountBalance = account.balance ?? "0.00";
}
final showAccount = account != null && !_hideAccountInfo;
final accountName = showAccount ? account.label : "";
final accountBalance = showAccount ? account.balance : null;
final assetName = widget.dashboardViewModel.balanceViewModel.showCombinedBalance
? ""
: walletBalanceRecord?.formattedAssetTitle ?? assetTitleFallback;
final assetName = widget.dashboardViewModel.balanceViewModel.showCombinedBalance
? ""
: walletBalanceRecord?.formattedAssetTitle ?? assetTitleFallback;
final List<BalanceCardAction> actions = widget.lightningMode
? [
BalanceCardAction(
label: S.current.bitcoin_lightning_deposit,
icon: Icons.arrow_downward,
onTap: depositToL2,
),
BalanceCardAction(
label: S.current.bitcoin_lightning_withdraw,
icon: Icons.arrow_upward,
onTap: withdrawFromL2,
)
]
: (widget.dashboardViewModel.isEnabledTradeAction &&
ProvidersHelper.getAvailableBuyProviderTypes().isNotEmpty)
? [
BalanceCardAction(
label: S.current.buy,
icon: Icons.arrow_forward_ios_rounded,
iconSize: 12,
onTap: () => Navigator.of(context).pushNamed(Routes.buySellPage),
)
]
: [];
final List<BalanceCardAction> actions = widget.lightningMode
? [
BalanceCardAction(
label: S.current.bitcoin_lightning_deposit,
icon: Icons.arrow_downward,
onTap: depositToL2,
),
BalanceCardAction(
label: S.current.bitcoin_lightning_withdraw,
icon: Icons.arrow_upward,
onTap: withdrawFromL2,
)
]
: (widget.dashboardViewModel.isEnabledTradeAction &&
ProvidersHelper.getAvailableBuyProviderTypes().isNotEmpty)
? [
BalanceCardAction(
label: S.current.buy,
icon: Icons.arrow_forward_ios_rounded,
iconSize: 12,
onTap: () {
showModalBottomSheet(
context: context, builder: (context) => BuySellSelectorModal());
},
)
]
: [];
return BalanceCard(
width: effectiveCardWidth,
accountName: accountName,
accountBalance: accountBalance,
designSwitchDuration: Duration(milliseconds: 150),
assetName: assetName,
capitalizeAssetName: _shouldCapitalizeAssetName(),
balance: walletBalance,
fiatCurrencyTitle: walletBalanceRecord?.fiatCurrencyTicker ??
widget.dashboardViewModel.settingsStore.fiatCurrency.title,
fiatFirst: widget.dashboardViewModel.balanceViewModel.showCombinedBalance,
fiatBalance: walletFiatBalance,
selected: _selectedIndex == visualIndex,
onCustomizeTapped: _selectedIndex == visualIndex ? widget.onCustomizeTapped : null,
design: cardDesign,
actions: actions,
);
}),
return BalanceCard(
width: effectiveCardWidth,
accountName: accountName,
accountBalance: accountBalance,
designSwitchDuration: Duration(milliseconds: 150),
assetName: assetName,
capitalizeAssetName: _shouldCapitalizeAssetName(),
balance: walletBalance,
fiatCurrencyTitle: walletBalanceRecord?.fiatCurrency?.title ??
widget.dashboardViewModel.settingsStore.fiatCurrency.title,
fiatFirst: widget.dashboardViewModel.balanceViewModel.showCombinedBalance,
fiatBalance: walletFiatBalance,
selected: _selectedIndex == visualIndex,
onCustomizeTapped: _selectedIndex == visualIndex ? widget.onCustomizeTapped : null,
design: cardDesign,
actions: actions,
);
}),
),
),
),
);
@@ -234,45 +267,46 @@ class _CardsViewState extends State<CardsView> {
}
}
double get effectiveCardWidth => min(MediaQuery.of(context).size.width * 0.878,
responsiveLayoutUtil.shouldRenderMobileUI ? 768 : 512);
double _getBoxHeight(int numCards, double overlapAmount) {
final double cardWidth = MediaQuery.of(context).size.width * 0.878;
final double effectiveCardWidth = min(cardWidth, 768);
return
/* height of initial card */
(2 / 3.2) * (effectiveCardWidth) +
/* height of bg card * amount of bg cards */
overlapAmount * ((numCards) - 1);
/* height of initial card */
(2 / 3.2) * (effectiveCardWidth) +
/* height of bg card * amount of bg cards */
overlapAmount * ((numCards) - 1);
}
int? _visualIndexForSelectedAccount(Map<int, int> order) {
final vm = widget.accountListViewModel;
if (vm == null || _hideAccountInfo) return null;
final selectedId = vm.selectedAccount?.id;
for (final entry in order.entries) {
if (entry.value == selectedId) return entry.key;
}
return null;
}
@override
Widget build(BuildContext context) {
return Observer(builder: (context) {
Widget build(BuildContext context) => Observer(builder: (context) {
final parentWidth = MediaQuery.of(context).size.width;
final children = <Widget>[];
int numCards = widget.dashboardViewModel.wallet.type == WalletType.bitcoin
? 1
: widget.dashboardViewModel.cardDesigns.length;
if (numCards == 0) numCards = 1;
if (_selectedIndex >= (numCards)) {
_selectedIndex = 0;
final stack = _hideAccountInfo
? const [0]
: BalanceCardLayout.stackFromOrder(widget.dashboardViewModel.cardOrder);
if (stack.isEmpty) return const SizedBox.shrink();
final numCards = stack.length;
if (_selectedIndex < 0 || _selectedIndex >= numCards) {
_selectedIndex = numCards - 1;
}
final order = {for (int i = 0; i < stack.length; i++) i: stack[i]};
Map<int, int> order = widget.dashboardViewModel.cardOrder.length != numCards
? Map<int, int>.fromEntries(
List.generate(numCards, (i) => MapEntry(i, i)),
)
: widget.dashboardViewModel.cardOrder;
for (int i = min(numCards - 1, maxCards); i >= 0; i--) {
int visualIndex = (_selectedIndex - i + numCards) % numCards;
if (order[visualIndex] == null) {
order = Map<int, int>.fromEntries(
List.generate(numCards, (i) => MapEntry(i, i)),
);
}
}
final followIndex = _visualIndexForSelectedAccount(order);
if (followIndex != null) _selectedIndex = followIndex;
final bool compactMode = numCards >= compactModeTreshold;
final double overlapAmount = compactMode ? 5.0 : 46.0;
@@ -281,14 +315,6 @@ class _CardsViewState extends State<CardsView> {
int realIndex = order[visualIndex]!;
if (visualIndex == _selectedIndex &&
widget.accountListViewModel != null &&
realIndex < widget.accountListViewModel!.accounts.length &&
widget.accountListViewModel?.selected.label !=
widget.accountListViewModel?.accounts[realIndex].label) {
widget.accountListViewModel!.select(widget.accountListViewModel!.accounts[realIndex]);
}
children.add(_buildCard(
visualIndex, realIndex, numCards, parentWidth, order, compactMode, overlapAmount));
}
@@ -306,7 +332,6 @@ class _CardsViewState extends State<CardsView> {
),
);
});
}
Future<void> depositToL2() async {
PaymentRequest? paymentRequest = null;
@@ -318,19 +343,19 @@ class _CardsViewState extends State<CardsView> {
}
} else if (widget.dashboardViewModel.type == WalletType.bitcoin) {
final depositAddress =
await bitcoin!.getUnusedSpakDepositAddress(widget.dashboardViewModel.wallet);
await bitcoin!.getUnusedSpakDepositAddress(widget.dashboardViewModel.wallet);
if ((depositAddress?.isNotEmpty ?? false)) {
paymentRequest = PaymentRequest.fromUri(Uri.parse("bitcoin:$depositAddress"));
}
}
if (FeatureFlag.hasNewUiExtraPages && widget.dashboardViewModel.type == WalletType.bitcoin) {
if (widget.dashboardViewModel.type == WalletType.bitcoin) {
final page = getIt.get<NewSendPage>(
param1: SendPageParams(
initialPaymentRequest: paymentRequest,
unspentCoinType: UnspentCoinType.nonMweb,
mode: SendPageModes.lightningDeposit,
));
initialPaymentRequest: paymentRequest,
unspentCoinType: UnspentCoinType.nonMweb,
mode: SendPageModes.lightningDeposit,
));
showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(128),
@@ -371,13 +396,13 @@ class _CardsViewState extends State<CardsView> {
unspentCoinType = UnspentCoinType.lightning;
}
if (FeatureFlag.hasNewUiExtraPages && widget.dashboardViewModel.type == WalletType.bitcoin) {
if (widget.dashboardViewModel.type == WalletType.bitcoin) {
final page = getIt.get<NewSendPage>(
param1: SendPageParams(
initialPaymentRequest: paymentRequest,
unspentCoinType: unspentCoinType,
mode: SendPageModes.lightningWithdrawal,
));
initialPaymentRequest: paymentRequest,
unspentCoinType: unspentCoinType,
mode: SendPageModes.lightningWithdrawal,
));
showCupertinoModalBottomSheet(
context: context,
barrierColor: Colors.black.withAlpha(128),
+51 -39
View File
@@ -21,51 +21,63 @@ class MwebAd extends StatelessWidget {
child: Column(
spacing: 12,
children: [
GestureDetector(
onTap: () => Navigator.of(context).pushNamed(Routes.mwebSettings),
child: Container(
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
HashImageWidget(
imageUrl: "assets/new-ui/settings_row_icons/mweb.svg",
width: 24,
height: 24,
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text(
S.of(context).mweb_ad,
softWrap: true,
style: TextStyle(fontSize: 12),
MergeSemantics(
child: Semantics(
button: true,
child: GestureDetector(
onTap: () => Navigator.of(context).pushNamed(Routes.mwebSettings),
child: Container(
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color: Theme.of(context).colorScheme.surfaceContainer),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ExcludeSemantics(
child: HashImageWidget(
imageUrl: "assets/new-ui/settings_row_icons/mweb.svg",
width: 24,
height: 24,
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text(
S.of(context).mweb_ad,
softWrap: true,
style: TextStyle(fontSize: 12),
),
),
),
Icon(
size: 16,
Icons.arrow_forward_ios,
color: Theme.of(context).colorScheme.primary,
)
],
),
Icon(
size: 16,
Icons.arrow_forward_ios,
color: Theme.of(context).colorScheme.primary,
)
],
),
),
),
),
),
GestureDetector(
onTap: () => dashboardViewModel.dismissMwebAd(false),
child: Text(
S.of(context).do_not_show_anymore,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary),
MergeSemantics(
child: Semantics(
button: true,
child: GestureDetector(
onTap: () => dashboardViewModel.dismissMwebAd(false),
child: Text(
S.of(context).do_not_show_anymore,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary),
),
),
),
)
],
@@ -0,0 +1,80 @@
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/src/widgets/hash_image_widget.dart";
import "package:hash_wallet/view_model/dashboard/dashboard_view_model.dart";
import "package:flutter/material.dart";
import "package:flutter_mobx/flutter_mobx.dart";
class SeedBackupReminderCard extends StatelessWidget {
const SeedBackupReminderCard({required this.dashboardViewModel, required this.onTap, super.key});
final DashboardViewModel dashboardViewModel;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Observer(
builder: (_) {
if (!dashboardViewModel.shouldShowSeedBackupReminder) {
return const SizedBox.shrink();
}
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(left: 18, right: 18, top: 24),
child: MergeSemantics(
child: Semantics(
button: true,
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
ExcludeSemantics(
child: HashImageWidget(
imageUrl: "assets/new-ui/key.svg",
width: 24,
height: 24,
colorFilter: ColorFilter.mode(colors.primary, BlendMode.srcIn),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
S.of(context).back_up_recovery_phrase,
style: textTheme.labelLarge?.copyWith(
color: colors.onSurface,
letterSpacing: -0.07,
),
),
Text(
S.of(context).back_up_recovery_phrase_description,
style: textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
letterSpacing: -0.06,
),
),
],
),
),
const SizedBox(width: 8),
Icon(Icons.chevron_right, size: 16, color: colors.onSurfaceVariant),
],
),
),
),
),
),
);
},
);
}
@@ -0,0 +1,162 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:flutter/material.dart';
enum _IconShape {
alreadyCircular,
clipOnly,
hollowNeedsBackdrop,
}
class TokenImageWidget extends StatefulWidget {
const TokenImageWidget({
super.key,
required this.imageUrl,
required this.size,
this.errorWidget,
this.semanticsLabel,
});
final String imageUrl;
final double size;
final Widget? errorWidget;
/// Accessible name for the token artwork. Leave `null` (the default) when the
/// token is already named by adjacent text, so the image stays decorative.
final String? semanticsLabel;
@override
State<TokenImageWidget> createState() => _TokenImageWidgetState();
}
class _TokenImageWidgetState extends State<TokenImageWidget> {
static final Map<String, _IconShape> _shapeCache = {};
_IconShape _shape = _IconShape.clipOnly;
@override
void initState() {
super.initState();
_resolveShape();
}
@override
void didUpdateWidget(TokenImageWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.imageUrl != widget.imageUrl) {
_resolveShape();
}
}
Future<void> _resolveShape() async {
final url = widget.imageUrl;
final cached = _shapeCache[url];
if (cached != null) {
if (mounted && _shape != cached) {
setState(() => _shape = cached);
}
return;
}
final shape = await _analyzeShape(url);
_shapeCache[url] = shape;
if (mounted) {
setState(() => _shape = shape);
}
}
Future<_IconShape> _analyzeShape(String url) async {
if (url.toLowerCase().endsWith('.svg')) return _IconShape.alreadyCircular;
final ImageProvider provider;
if (url.startsWith('assets/')) {
provider = AssetImage(url);
} else if (url.startsWith('http')) {
provider = NetworkImage(url);
} else {
return _IconShape.clipOnly;
}
try {
final completer = Completer<ui.Image>();
final stream = provider.resolve(ImageConfiguration.empty);
late final ImageStreamListener listener;
listener = ImageStreamListener(
(info, _) {
if (!completer.isCompleted) completer.complete(info.image);
},
onError: (error, _) {
if (!completer.isCompleted) completer.completeError(error);
},
);
stream.addListener(listener);
final image = await completer.future;
stream.removeListener(listener);
final byteData = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
if (byteData == null) return _IconShape.clipOnly;
final totalPixels = image.width * image.height;
var transparentPixels = 0;
for (var i = 0; i < totalPixels; i++) {
final alpha = byteData.getUint8(i * 4 + 3);
if (alpha < 128) transparentPixels++;
}
final ratio = transparentPixels / totalPixels;
if (ratio > 0.15 && ratio < 0.30) {
return _IconShape.alreadyCircular;
}
if (ratio >= 0.30) {
return _IconShape.hollowNeedsBackdrop;
}
return _IconShape.clipOnly;
} catch (e) {
printV('TokenImageWidget: failed to analyze $url: $e');
return _IconShape.clipOnly;
}
}
@override
Widget build(BuildContext context) {
final image = SizedBox(
width: widget.size,
height: widget.size,
child: HashImageWidget(
imageUrl: widget.imageUrl,
width: widget.size,
height: widget.size,
fit: BoxFit.cover,
filterQuality: FilterQuality.high,
errorWidget: widget.errorWidget,
semanticsLabel: widget.semanticsLabel,
),
);
switch (_shape) {
case _IconShape.alreadyCircular:
return image;
case _IconShape.clipOnly:
return ClipOval(child: image);
case _IconShape.hollowNeedsBackdrop:
return Container(
width: widget.size,
height: widget.size,
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
child: image,
);
}
}
}
@@ -1,3 +1,4 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:flutter/material.dart';
@@ -9,28 +10,36 @@ class ChainIcon extends StatelessWidget {
{super.key,
required this.iconPath,
required this.dashboardViewModel,
required this.isSyncHeavy});
required this.isSyncHeavy,
required this.showSyncedMessage});
final String iconPath;
final bool isSyncHeavy;
final DashboardViewModel dashboardViewModel;
final bool showSyncedMessage;
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) {
final progress = dashboardViewModel.status.progress();
final done = !isSyncHeavy || progress >= 1;
final done = !showSyncedMessage && (!isSyncHeavy || progress >= 1);
return Stack(
children: [
AnimatedOpacity(
duration: Duration(milliseconds: 100),
opacity: done ? 0 : 1,
child: CircularProgressIndicator(
value: progress,
color: Color(0xFFFFB84E),
strokeWidth: 2,
// Faded out means "nothing to report", so it must leave the tree too.
child: ExcludeSemantics(
excluding: done || showSyncedMessage,
child: CircularProgressIndicator(
value: progress,
color: showSyncedMessage ? Color(0xFFFF12A439) : Color(0xFFFFB84E),
strokeWidth: 2,
semanticsLabel: S.of(context).synchronizing,
semanticsValue: "${(progress * 100).round()}%",
),
),
),
AnimatedScale(

Some files were not shown because too many files have changed in this diff Show More