Merge branch 'ui-fixes' into trocador

This commit is contained in:
julian 2023-05-01 13:50:27 -06:00
commit 34f7d80051
10 changed files with 455 additions and 119 deletions

View file

@ -14,6 +14,8 @@ import 'package:stackwallet/utilities/show_loading.dart';
import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/utilities/text_styles.dart';
import 'package:stackwallet/utilities/theme/stack_colors.dart'; import 'package:stackwallet/utilities/theme/stack_colors.dart';
import 'package:stackwallet/utilities/util.dart'; import 'package:stackwallet/utilities/util.dart';
import 'package:stackwallet/widgets/desktop/primary_button.dart';
import 'package:stackwallet/widgets/dialogs/basic_dialog.dart';
import 'package:stackwallet/widgets/icon_widgets/eth_token_icon.dart'; import 'package:stackwallet/widgets/icon_widgets/eth_token_icon.dart';
import 'package:stackwallet/widgets/rounded_white_container.dart'; import 'package:stackwallet/widgets/rounded_white_container.dart';
@ -36,6 +38,36 @@ class _MyTokenSelectItemState extends ConsumerState<MyTokenSelectItem> {
late final CachedEthTokenBalance cachedBalance; late final CachedEthTokenBalance cachedBalance;
Future<bool> _loadTokenWallet(
BuildContext context,
WidgetRef ref,
) async {
try {
await ref.read(tokenServiceProvider)!.initialize();
return true;
} catch (_) {
await showDialog<void>(
barrierDismissible: false,
context: context,
builder: (context) => BasicDialog(
title: "Failed to load token data",
desktopHeight: double.infinity,
desktopWidth: 450,
rightButton: PrimaryButton(
label: "OK",
onPressed: () {
Navigator.of(context).pop();
if (!isDesktop) {
Navigator.of(context).pop();
}
},
),
),
);
return false;
}
}
void _onPressed() async { void _onPressed() async {
ref.read(tokenServiceStateProvider.state).state = EthTokenWallet( ref.read(tokenServiceStateProvider.state).state = EthTokenWallet(
token: widget.token, token: widget.token,
@ -49,13 +81,17 @@ class _MyTokenSelectItemState extends ConsumerState<MyTokenSelectItem> {
), ),
); );
await showLoading<void>( final success = await showLoading<bool>(
whileFuture: ref.read(tokenServiceProvider)!.initialize(), whileFuture: _loadTokenWallet(context, ref),
context: context, context: context,
isDesktop: isDesktop, isDesktop: isDesktop,
message: "Loading ${widget.token.name}", message: "Loading ${widget.token.name}",
); );
if (!success) {
return;
}
if (mounted) { if (mounted) {
await Navigator.of(context).pushNamed( await Navigator.of(context).pushNamed(
isDesktop ? DesktopTokenView.routeName : TokenView.routeName, isDesktop ? DesktopTokenView.routeName : TokenView.routeName,

View file

@ -53,6 +53,7 @@ import 'package:stackwallet/widgets/conditional_parent.dart';
import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart';
import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart';
import 'package:stackwallet/widgets/custom_loading_overlay.dart'; import 'package:stackwallet/widgets/custom_loading_overlay.dart';
import 'package:stackwallet/widgets/desktop/secondary_button.dart';
import 'package:stackwallet/widgets/loading_indicator.dart'; import 'package:stackwallet/widgets/loading_indicator.dart';
import 'package:stackwallet/widgets/stack_dialog.dart'; import 'package:stackwallet/widgets/stack_dialog.dart';
import 'package:stackwallet/widgets/wallet_navigation_bar/components/icons/buy_nav_icon.dart'; import 'package:stackwallet/widgets/wallet_navigation_bar/components/icons/buy_nav_icon.dart';
@ -420,6 +421,33 @@ class _WalletViewState extends ConsumerState<WalletView> {
eventBus: null, eventBus: null,
textColor: textColor:
Theme.of(context).extension<StackColors>()!.textDark, Theme.of(context).extension<StackColors>()!.textDark,
actionButton: SecondaryButton(
label: "Cancel",
onPressed: () async {
await showDialog<void>(
context: context,
builder: (context) => StackDialog(
title: "Warning!",
message: "Skipping this process can completely"
" break your wallet. It is only meant to be done in"
" emergency situations where the migration fails"
" and will not let you continue. Still skip?",
leftButton: SecondaryButton(
label: "Cancel",
onPressed:
Navigator.of(context, rootNavigator: true).pop,
),
rightButton: SecondaryButton(
label: "Ok",
onPressed: () {
Navigator.of(context, rootNavigator: true).pop();
setState(() => _rescanningOnOpen = false);
},
),
),
);
},
),
), ),
) )
], ],
@ -790,7 +818,6 @@ class _WalletViewState extends ConsumerState<WalletView> {
label: "Receive", label: "Receive",
icon: const ReceiveNavIcon(), icon: const ReceiveNavIcon(),
onTap: () { onTap: () {
final coin = ref.read(managerProvider).coin;
if (mounted) { if (mounted) {
unawaited( unawaited(
Navigator.of(context).pushNamed( Navigator.of(context).pushNamed(

View file

@ -31,7 +31,11 @@ import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart';
import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart';
import 'package:stackwallet/widgets/custom_loading_overlay.dart'; import 'package:stackwallet/widgets/custom_loading_overlay.dart';
import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart';
import 'package:stackwallet/widgets/desktop/desktop_dialog.dart';
import 'package:stackwallet/widgets/desktop/desktop_dialog_close_button.dart';
import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart';
import 'package:stackwallet/widgets/desktop/primary_button.dart';
import 'package:stackwallet/widgets/desktop/secondary_button.dart';
import 'package:stackwallet/widgets/hover_text_field.dart'; import 'package:stackwallet/widgets/hover_text_field.dart';
import 'package:stackwallet/widgets/rounded_white_container.dart'; import 'package:stackwallet/widgets/rounded_white_container.dart';
@ -150,6 +154,83 @@ class _DesktopWalletViewState extends ConsumerState<DesktopWalletView> {
subMessage: "This only needs to run once per wallet", subMessage: "This only needs to run once per wallet",
eventBus: null, eventBus: null,
textColor: Theme.of(context).extension<StackColors>()!.textDark, textColor: Theme.of(context).extension<StackColors>()!.textDark,
actionButton: SecondaryButton(
label: "Skip",
buttonHeight: ButtonHeight.l,
onPressed: () async {
await showDialog<void>(
context: context,
builder: (context) => DesktopDialog(
maxWidth: 500,
maxHeight: double.infinity,
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 32),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Warning!",
style: STextStyles.desktopH3(context),
),
const DesktopDialogCloseButton(),
],
),
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 32),
child: Text(
"Skipping this process can completely"
" break your wallet. It is only meant to be done in"
" emergency situations where the migration fails"
" and will not let you continue. Still skip?",
style: STextStyles.desktopTextSmall(context),
),
),
const SizedBox(
height: 32,
),
Padding(
padding: const EdgeInsets.all(32),
child: Row(
children: [
Expanded(
child: SecondaryButton(
label: "Cancel",
buttonHeight: ButtonHeight.l,
onPressed: Navigator.of(context,
rootNavigator: true)
.pop,
),
),
const SizedBox(
width: 16,
),
Expanded(
child: PrimaryButton(
label: "Ok",
buttonHeight: ButtonHeight.l,
onPressed: () {
Navigator.of(context,
rootNavigator: true)
.pop();
setState(
() => _rescanningOnOpen = false);
},
),
),
],
),
)
],
),
),
);
},
),
), ),
) )
], ],

View file

@ -1949,8 +1949,6 @@ class BitcoinCashWallet extends CoinServiceAPI
List<Map<String, dynamic>> allTransactions = []; List<Map<String, dynamic>> allTransactions = [];
final currentHeight = await chainHeight;
for (final txHash in allTxHashes) { for (final txHash in allTxHashes) {
final storedTx = await db final storedTx = await db
.getTransactions(walletId) .getTransactions(walletId)
@ -1958,7 +1956,9 @@ class BitcoinCashWallet extends CoinServiceAPI
.txidEqualTo(txHash["tx_hash"] as String) .txidEqualTo(txHash["tx_hash"] as String)
.findFirst(); .findFirst();
if (storedTx == null || storedTx.address.value == null if (storedTx == null ||
storedTx.address.value == null ||
storedTx.height == null
// zero conf messes this up // zero conf messes this up
// !storedTx.isConfirmed(currentHeight, MINIMUM_CONFIRMATIONS) // !storedTx.isConfirmed(currentHeight, MINIMUM_CONFIRMATIONS)
) { ) {

View file

@ -253,13 +253,17 @@ class EthTokenWallet extends ChangeNotifier with EthTokenCache {
); );
_credentials = web3dart.EthPrivateKey.fromHex(privateKey); _credentials = web3dart.EthPrivateKey.fromHex(privateKey);
_deployedContract = web3dart.DeployedContract( try {
ContractAbiExtensions.fromJsonList( _deployedContract = web3dart.DeployedContract(
jsonList: tokenContract.abi!, ContractAbiExtensions.fromJsonList(
name: tokenContract.name, jsonList: tokenContract.abi!,
), name: tokenContract.name,
contractAddress, ),
); contractAddress,
);
} catch (_) {
rethrow;
}
try { try {
_sendFunction = _deployedContract.function('transfer'); _sendFunction = _deployedContract.function('transfer');
@ -328,13 +332,17 @@ class EthTokenWallet extends ChangeNotifier with EthTokenCache {
//==================================================================== //====================================================================
} }
_deployedContract = web3dart.DeployedContract( try {
ContractAbiExtensions.fromJsonList( _deployedContract = web3dart.DeployedContract(
jsonList: tokenContract.abi!, ContractAbiExtensions.fromJsonList(
name: tokenContract.name, jsonList: tokenContract.abi!,
), name: tokenContract.name,
contractAddress, ),
); contractAddress,
);
} catch (_) {
rethrow;
}
_sendFunction = _deployedContract.function('transfer'); _sendFunction = _deployedContract.function('transfer');

View file

@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:stackwallet/utilities/logger.dart';
import 'package:web3dart/web3dart.dart'; import 'package:web3dart/web3dart.dart';
extension ContractAbiExtensions on ContractAbi { extension ContractAbiExtensions on ContractAbi {
@ -7,50 +8,61 @@ extension ContractAbiExtensions on ContractAbi {
required String name, required String name,
required String jsonList, required String jsonList,
}) { }) {
final List<ContractFunction> functions = []; try {
final List<ContractEvent> events = []; final List<ContractFunction> functions = [];
final List<ContractEvent> events = [];
final list = List<Map<String, dynamic>>.from(jsonDecode(jsonList) as List); final list =
List<Map<String, dynamic>>.from(jsonDecode(jsonList) as List);
for (final json in list) { for (final json in list) {
final type = json["type"] as String; final type = json["type"] as String;
final name = json["name"] as String? ?? ""; final name = json["name"] as String? ?? "";
if (type == "event") { if (type == "event") {
final anonymous = json["anonymous"] as bool? ?? false; final anonymous = json["anonymous"] as bool? ?? false;
final List<EventComponent<dynamic>> components = []; final List<EventComponent<dynamic>> components = [];
for (final input in json["inputs"] as List) { if (json["inputs"] is List) {
components.add( for (final input in json["inputs"] as List) {
EventComponent( components.add(
_parseParam(input as Map), EventComponent(
input['indexed'] as bool? ?? false, _parseParam(input as Map),
), input['indexed'] as bool? ?? false,
); ),
} );
}
}
events.add(ContractEvent(anonymous, name, components)); events.add(ContractEvent(anonymous, name, components));
} else { } else {
final mutability = _mutabilityNames[json['stateMutability']]; final mutability = _mutabilityNames[json['stateMutability']];
final parsedType = _functionTypeNames[json['type']]; final parsedType = _functionTypeNames[json['type']];
if (parsedType != null) { if (parsedType != null) {
final inputs = _parseParams(json['inputs'] as List?); final inputs = _parseParams(json['inputs'] as List?);
final outputs = _parseParams(json['outputs'] as List?); final outputs = _parseParams(json['outputs'] as List?);
functions.add( functions.add(
ContractFunction( ContractFunction(
name, name,
inputs, inputs,
outputs: outputs, outputs: outputs,
type: parsedType, type: parsedType,
mutability: mutability ?? StateMutability.nonPayable, mutability: mutability ?? StateMutability.nonPayable,
), ),
); );
}
} }
} }
}
return ContractAbi(name, functions, events); return ContractAbi(name, functions, events);
} catch (e, s) {
Logging.instance.log(
"Failed to parse ABI for $name: $e\n$s",
level: LogLevel.Error,
);
rethrow;
}
} }
static const Map<String, ContractFunctionType> _functionTypeNames = { static const Map<String, ContractFunctionType> _functionTypeNames = {

View file

@ -5,6 +5,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/utilities/text_styles.dart';
import 'package:stackwallet/utilities/theme/stack_colors.dart'; import 'package:stackwallet/utilities/theme/stack_colors.dart';
import 'package:stackwallet/utilities/util.dart';
import 'package:stackwallet/widgets/conditional_parent.dart';
import 'package:stackwallet/widgets/loading_indicator.dart'; import 'package:stackwallet/widgets/loading_indicator.dart';
class CustomLoadingOverlay extends ConsumerStatefulWidget { class CustomLoadingOverlay extends ConsumerStatefulWidget {
@ -14,12 +16,14 @@ class CustomLoadingOverlay extends ConsumerStatefulWidget {
this.subMessage, this.subMessage,
required this.eventBus, required this.eventBus,
this.textColor, this.textColor,
this.actionButton,
}) : super(key: key); }) : super(key: key);
final String message; final String message;
final String? subMessage; final String? subMessage;
final EventBus? eventBus; final EventBus? eventBus;
final Color? textColor; final Color? textColor;
final Widget? actionButton;
@override @override
ConsumerState<CustomLoadingOverlay> createState() => ConsumerState<CustomLoadingOverlay> createState() =>
@ -30,6 +34,7 @@ class _CustomLoadingOverlayState extends ConsumerState<CustomLoadingOverlay> {
double _percent = 0; double _percent = 0;
late final StreamSubscription<double>? subscription; late final StreamSubscription<double>? subscription;
final bool isDesktop = Util.isDesktop;
@override @override
void initState() { void initState() {
@ -49,68 +54,97 @@ class _CustomLoadingOverlayState extends ConsumerState<CustomLoadingOverlay> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Material(
crossAxisAlignment: CrossAxisAlignment.stretch, color: Colors.transparent,
mainAxisAlignment: MainAxisAlignment.center, child: ConditionalParent(
children: [ condition: widget.actionButton != null,
Material( builder: (child) => Stack(
color: Colors.transparent, children: [
child: Center( child,
child: Column( if (isDesktop)
children: [ Row(
Text( mainAxisAlignment: MainAxisAlignment.end,
widget.message, children: [
textAlign: TextAlign.center, Padding(
style: STextStyles.pageTitleH2(context).copyWith( padding: const EdgeInsets.all(16),
color: widget.textColor ?? child: SizedBox(
Theme.of(context) width: 100,
.extension<StackColors>()! child: widget.actionButton!,
.loadingOverlayTextColor, ),
),
],
),
if (!isDesktop)
Positioned(
bottom: 1,
left: 0,
right: 1,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(child: widget.actionButton!),
],
), ),
), ),
if (widget.eventBus != null) ),
const SizedBox( ],
height: 10, ),
), child: Column(
if (widget.eventBus != null) crossAxisAlignment: CrossAxisAlignment.stretch,
Text( mainAxisAlignment: MainAxisAlignment.center,
"${(_percent * 100).toStringAsFixed(2)}%", children: [
style: STextStyles.pageTitleH2(context).copyWith( Text(
color: widget.textColor ?? widget.message,
Theme.of(context) textAlign: TextAlign.center,
.extension<StackColors>()! style: STextStyles.pageTitleH2(context).copyWith(
.loadingOverlayTextColor, color: widget.textColor ??
), Theme.of(context)
), .extension<StackColors>()!
if (widget.subMessage != null) .loadingOverlayTextColor,
const SizedBox( ),
height: 10,
),
if (widget.subMessage != null)
Text(
widget.subMessage!,
textAlign: TextAlign.center,
style: STextStyles.pageTitleH2(context).copyWith(
fontSize: 14,
color: widget.textColor ??
Theme.of(context)
.extension<StackColors>()!
.loadingOverlayTextColor,
),
)
],
), ),
), if (widget.eventBus != null)
const SizedBox(
height: 10,
),
if (widget.eventBus != null)
Text(
"${(_percent * 100).toStringAsFixed(2)}%",
style: STextStyles.pageTitleH2(context).copyWith(
color: widget.textColor ??
Theme.of(context)
.extension<StackColors>()!
.loadingOverlayTextColor,
),
),
if (widget.subMessage != null)
const SizedBox(
height: 10,
),
if (widget.subMessage != null)
Text(
widget.subMessage!,
textAlign: TextAlign.center,
style: STextStyles.pageTitleH2(context).copyWith(
fontSize: 14,
color: widget.textColor ??
Theme.of(context)
.extension<StackColors>()!
.loadingOverlayTextColor,
),
),
const SizedBox(
height: 64,
),
const Center(
child: LoadingIndicator(
width: 100,
),
),
],
), ),
const SizedBox( ),
height: 64,
),
const Center(
child: LoadingIndicator(
width: 100,
),
),
],
); );
} }
} }

View file

@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:stackwallet/utilities/text_styles.dart';
import 'package:stackwallet/utilities/util.dart';
import 'package:stackwallet/widgets/desktop/desktop_dialog.dart';
import 'package:stackwallet/widgets/desktop/desktop_dialog_close_button.dart';
import 'package:stackwallet/widgets/stack_dialog.dart';
class BasicDialog extends StatelessWidget {
const BasicDialog({
Key? key,
this.leftButton,
this.rightButton,
this.icon,
required this.title,
this.message,
this.desktopHeight = 474,
this.desktopWidth = 641,
this.canPopWithBackButton = false,
}) : super(key: key);
final Widget? leftButton;
final Widget? rightButton;
final Widget? icon;
final String title;
final String? message;
final double? desktopHeight;
final double desktopWidth;
final bool canPopWithBackButton;
@override
Widget build(BuildContext context) {
final isDesktop = Util.isDesktop;
if (isDesktop) {
return DesktopDialog(
maxHeight: desktopHeight,
maxWidth: desktopWidth,
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 32),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: STextStyles.desktopH3(context),
),
const DesktopDialogCloseButton(),
],
),
),
if (message != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
message!,
style: STextStyles.desktopTextSmall(context),
),
),
if (leftButton != null || rightButton != null)
const SizedBox(
height: 32,
),
if (leftButton != null || rightButton != null)
Padding(
padding: const EdgeInsets.all(32),
child: Row(
children: [
leftButton != null
? Expanded(child: leftButton!)
: const Spacer(),
const SizedBox(
width: 16,
),
rightButton != null
? Expanded(child: rightButton!)
: const Spacer(),
],
),
)
],
),
);
} else {
return WillPopScope(
onWillPop: () async {
return canPopWithBackButton;
},
child: StackDialog(
title: title,
leftButton: leftButton,
rightButton: rightButton,
icon: icon,
message: message,
),
);
}
}
}

View file

@ -19,6 +19,8 @@ import 'package:stackwallet/utilities/enums/coin_enum.dart';
import 'package:stackwallet/utilities/show_loading.dart'; import 'package:stackwallet/utilities/show_loading.dart';
import 'package:stackwallet/utilities/util.dart'; import 'package:stackwallet/utilities/util.dart';
import 'package:stackwallet/widgets/conditional_parent.dart'; import 'package:stackwallet/widgets/conditional_parent.dart';
import 'package:stackwallet/widgets/desktop/primary_button.dart';
import 'package:stackwallet/widgets/dialogs/basic_dialog.dart';
import 'package:stackwallet/widgets/rounded_white_container.dart'; import 'package:stackwallet/widgets/rounded_white_container.dart';
import 'package:stackwallet/widgets/wallet_info_row/wallet_info_row.dart'; import 'package:stackwallet/widgets/wallet_info_row/wallet_info_row.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -37,7 +39,7 @@ class SimpleWalletCard extends ConsumerWidget {
final bool popPrevious; final bool popPrevious;
final NavigatorState? desktopNavigatorState; final NavigatorState? desktopNavigatorState;
Future<void> _loadTokenWallet( Future<bool> _loadTokenWallet(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
Manager manager, Manager manager,
@ -52,7 +54,31 @@ class SimpleWalletCard extends ConsumerWidget {
), ),
); );
await ref.read(tokenServiceProvider)!.initialize(); try {
await ref.read(tokenServiceProvider)!.initialize();
return true;
} catch (_) {
await showDialog<void>(
barrierDismissible: false,
context: context,
builder: (context) => BasicDialog(
title: "Failed to load token data",
desktopHeight: double.infinity,
desktopWidth: 450,
rightButton: PrimaryButton(
label: "OK",
onPressed: () {
Navigator.of(context).pop();
Navigator.of(context).pop();
if (desktopNavigatorState == null) {
Navigator.of(context).pop();
}
},
),
),
);
return false;
}
} }
void _openWallet(BuildContext context, WidgetRef ref) async { void _openWallet(BuildContext context, WidgetRef ref) async {
@ -91,13 +117,21 @@ class SimpleWalletCard extends ConsumerWidget {
final contract = final contract =
ref.read(mainDBProvider).getEthContractSync(contractAddress!)!; ref.read(mainDBProvider).getEthContractSync(contractAddress!)!;
await showLoading<void>( final success = await showLoading<bool>(
whileFuture: _loadTokenWallet(context, ref, manager, contract), whileFuture: _loadTokenWallet(
context: context, desktopNavigatorState?.context ?? context,
ref,
manager,
contract),
context: desktopNavigatorState?.context ?? context,
opaqueBG: true, opaqueBG: true,
message: "Loading ${contract.name}", message: "Loading ${contract.name}",
); );
if (!success) {
return;
}
if (desktopNavigatorState == null) { if (desktopNavigatorState == null) {
// pop loading // pop loading
nav.pop(); nav.pop();

View file

@ -11,7 +11,7 @@ description: Stack Wallet
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.7.4+164 version: 1.7.4+165
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"