From 0e39d6db281cdf4168e78db1fd1f63103bc174e2 Mon Sep 17 00:00:00 2001 From: zaelgohary Date: Sun, 1 Jun 2025 12:04:11 +0300 Subject: [PATCH 1/6] Add contracts screen & include it in wallet bottom nav --- app/lib/screens/wallets/contracts.dart | 399 ++++++++++++++++++ app/lib/screens/wallets/wallet_details.dart | 10 +- app/lib/services/gridproxy_service.dart | 15 + app/lib/widgets/market/order_card.dart | 1 + app/lib/widgets/wallets/contract_details.dart | 211 +++++++++ 5 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 app/lib/screens/wallets/contracts.dart create mode 100644 app/lib/widgets/wallets/contract_details.dart diff --git a/app/lib/screens/wallets/contracts.dart b/app/lib/screens/wallets/contracts.dart new file mode 100644 index 000000000..fbba8aba3 --- /dev/null +++ b/app/lib/screens/wallets/contracts.dart @@ -0,0 +1,399 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:threebotlogin/helpers/logger.dart'; +import 'package:threebotlogin/main.dart'; +import 'package:threebotlogin/models/wallet.dart'; +import 'package:threebotlogin/services/gridproxy_service.dart'; +import 'package:threebotlogin/services/tfchain_service.dart'; +import 'package:gridproxy_client/models/contracts.dart'; +import 'package:threebotlogin/widgets/wallets/contract_details.dart'; +import 'dart:convert'; + +class WalletContractsWidget extends ConsumerStatefulWidget { + const WalletContractsWidget({super.key, required this.wallet}); + final Wallet wallet; + + @override + ConsumerState createState() => _WalletContractsWidgetState(); +} + +class _WalletContractsWidgetState extends ConsumerState { + bool loading = true; + bool failed = false; + List contracts = []; + + @override + void initState() { + super.initState(); + _loadContracts(); + } + + Future _loadContracts() async { + setState(() { + loading = true; + failed = false; + }); + + try { + int? twinId; + try { + twinId = await getTwinId(widget.wallet.tfchainSecret); + logger.i('Found twin ID: $twinId for wallet: ${widget.wallet.tfchainAddress}'); + } catch (e) { + logger.w('Could not get twin ID: $e'); + } + + if (twinId != null) { + try { + final contractsList = await getContractsByTwinId(twinId); + contracts = contractsList.cast(); + logger.i('Loaded ${contracts.length} contracts for twin ID: $twinId'); + } catch (e) { + logger.w('Error fetching contracts by twin ID: $e'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: ${e.toString().split(': ').last}'), + duration: const Duration(seconds: 3), + ), + ); + } + } + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not find a twin ID for this wallet. No contracts can be displayed.'), + duration: Duration(seconds: 5), + ), + ); + } + } + } catch (e) { + logger.e('Failed to load contracts: $e'); + setState(() { + failed = true; + }); + if (context.mounted) { + final loadingContractsFailure = SnackBar( + content: Text( + 'Failed to load contracts: ${e.toString().split(': ').last}', + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Theme.of(context).colorScheme.errorContainer), + ), + duration: const Duration(seconds: 3), + ); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar(loadingContractsFailure); + } + } finally { + setState(() { + loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + Widget content; + + if (loading) { + content = Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 15), + Text( + 'Loading Contracts...', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold), + ), + ], + ), + ); + } else if (failed) { + content = Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48), + const SizedBox(height: 15), + Text( + 'Failed to load contracts', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 15), + ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + onPressed: _loadContracts, + ), + ], + ), + ); + } else if (contracts.isEmpty) { + content = Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.description_outlined, + size: 64, + color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + 'No contracts found for this wallet', + style: Theme.of(context).textTheme.titleMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Text( + 'Contracts will appear here when you deploy workloads on the ThreeFold Grid', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } else { + content = RefreshIndicator( + onRefresh: _loadContracts, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8.0), + itemCount: contracts.length, + itemBuilder: (context, index) { + final contract = contracts[index]; + return _buildContractListItem(context, contract); + }, + ), + ); + } + + return content; + } + + Widget _buildContractListItem(BuildContext context, ContractInfo contract) { + final contractId = contract.contract_id.toString(); + final contractType = contract.type; + final state = contract.state; + + String name = ''; + + if (contract.details != null && contract.details is Map) { + final details = contract.details as Map; + + if (contractType.toLowerCase() == 'name' && details.containsKey('name')) { + name = details['name']?.toString() ?? ''; + } + else if (contractType.toLowerCase() == 'node' && + details.containsKey('deployment_data') && + details['deployment_data'] is String && + details['deployment_data'].isNotEmpty) { + try { + final Map decoded = json.decode(details['deployment_data']); + name = decoded['name']?.toString() ?? ''; + } catch (e) { + logger.d('Could not parse deployment_data JSON: $e'); + } + } + } + + // Get icon based on contract type + IconData typeIcon = Icons.description_outlined; + if (contractType.toLowerCase() == 'name') { + typeIcon = Icons.dns_outlined; + } else if (contractType.toLowerCase() == 'node') { + typeIcon = Icons.computer_outlined; + } else if (contractType.toLowerCase() == 'rent') { + typeIcon = Icons.storage_outlined; + } + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.5), + width: 0.5, + ), + ), + child: InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ContractDetailScreen(contract: contract), + ), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + typeIcon, + color: Theme.of(context).colorScheme.onSecondaryContainer, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Contract $contractId', + style: Theme.of(context).textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + contractType, + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + if (name.isNotEmpty) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + name, + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ], + ), + ), + _buildStatusBadge(context, state), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'View Details', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + Icon( + Icons.arrow_forward_ios, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildStatusBadge(BuildContext context, String status) { + final lowerStatus = status.toLowerCase(); + Color backgroundColor; + Color textColor; + + if (lowerStatus == 'created') { + backgroundColor = Theme.of(context).colorScheme.primaryContainer; + textColor = Theme.of(context).colorScheme.onPrimary; + } else { + backgroundColor = Theme.of(context).colorScheme.warningContainer; + textColor = Theme.of(context).colorScheme.warning; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + _capitalizeFirstLetter(lowerStatus), + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: textColor, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + String _capitalizeFirstLetter(String text) { + if (text.isEmpty) return text; + return text[0].toUpperCase() + text.substring(1); + } +} + +class ContractDetailScreen extends StatelessWidget { + final ContractInfo contract; + + const ContractDetailScreen({ + super.key, + required this.contract, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Contract ${contract.contract_id}'), + elevation: 0, + ), + body: SingleChildScrollView( + child: ContractDetails( + contract: contract, + ), + ), + ); + } +} diff --git a/app/lib/screens/wallets/wallet_details.dart b/app/lib/screens/wallets/wallet_details.dart index 237232bc5..253420773 100644 --- a/app/lib/screens/wallets/wallet_details.dart +++ b/app/lib/screens/wallets/wallet_details.dart @@ -4,6 +4,7 @@ import 'package:threebotlogin/models/wallet.dart'; import 'package:threebotlogin/providers/wallets_provider.dart'; import 'package:threebotlogin/screens/wallets/transactions.dart'; import 'package:threebotlogin/screens/wallets/wallet_assets.dart'; +import 'package:threebotlogin/screens/wallets/contracts.dart'; import 'package:threebotlogin/screens/wallets/wallet_info.dart'; class WalletDetailsScreen extends ConsumerStatefulWidget { @@ -33,6 +34,8 @@ class _WalletDetailsScreenState extends ConsumerState { wallet: widget.wallet, ); } else if (currentScreenIndex == 2) { + content = WalletContractsWidget(wallet: widget.wallet); + } else if (currentScreenIndex == 3) { content = WalletDetailsWidget(wallet: widget.wallet); } else { content = WalletAssetsWidget( @@ -40,15 +43,20 @@ class _WalletDetailsScreenState extends ConsumerState { ); } return Scaffold( - appBar: AppBar(title: Text(widget.wallet.name)), + appBar: AppBar( + title: Text(widget.wallet.name), + ), bottomNavigationBar: BottomNavigationBar( onTap: _selectScreen, currentIndex: currentScreenIndex, + type: BottomNavigationBarType.fixed, items: const [ BottomNavigationBarItem( icon: Icon(Icons.account_balance), label: 'Assets'), BottomNavigationBarItem( icon: Icon(Icons.swap_horiz), label: 'Transactions'), + BottomNavigationBarItem( + icon: Icon(Icons.description), label: 'Contracts'), BottomNavigationBarItem(icon: Icon(Icons.info), label: 'Info'), ], ), diff --git a/app/lib/services/gridproxy_service.dart b/app/lib/services/gridproxy_service.dart index 1b215d328..6da27c9dd 100644 --- a/app/lib/services/gridproxy_service.dart +++ b/app/lib/services/gridproxy_service.dart @@ -1,4 +1,5 @@ import 'package:gridproxy_client/gridproxy_client.dart'; +import 'package:gridproxy_client/models/contracts.dart'; import 'package:gridproxy_client/models/nodes.dart'; import 'package:threebotlogin/helpers/globals.dart'; import 'package:threebotlogin/main.reflectable.dart'; @@ -16,6 +17,20 @@ Future getMySpending() async { return spending.overall_consumption; } +Future> getContractsByTwinId(int twinId) async { + try { + initializeReflectable(); + final gridproxyUrl = Globals().gridproxyUrl; + + final client = GridProxyClient(gridproxyUrl); + final contracts = + await client.contracts.list(ContractInfoQueryParams(twin_id: twinId, state: ContractState.GracePeriod)); + return contracts; + } catch (e) { + throw Exception('Error fetching contracts: $e'); + } +} + Future> getFarmsByTwinId(int twinId, {bool hasUpNode = false}) async { try { diff --git a/app/lib/widgets/market/order_card.dart b/app/lib/widgets/market/order_card.dart index f5556db6f..6f4b13bac 100644 --- a/app/lib/widgets/market/order_card.dart +++ b/app/lib/widgets/market/order_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:threebotlogin/helpers/logger.dart'; import 'package:threebotlogin/models/offer.dart'; +// ignore: depend_on_referenced_packages import 'package:intl/intl.dart'; import 'package:threebotlogin/models/wallet.dart' as Wallet; import 'package:threebotlogin/screens/market/order_details.dart'; diff --git a/app/lib/widgets/wallets/contract_details.dart b/app/lib/widgets/wallets/contract_details.dart new file mode 100644 index 000000000..0de5bbb13 --- /dev/null +++ b/app/lib/widgets/wallets/contract_details.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:gridproxy_client/models/contracts.dart'; +// ignore: depend_on_referenced_packages +import 'package:intl/intl.dart'; +import 'package:threebotlogin/helpers/logger.dart'; +import 'dart:convert'; + +class ContractDetails extends StatelessWidget { + final ContractInfo contract; + + const ContractDetails({ + super.key, + required this.contract, + }); + + String _formatDate(int timestamp) { + if (timestamp <= 0) return 'N/A'; + + try { + final date = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); + return DateFormat('MMM d, yyyy').format(date); + } catch (e) { + return 'Invalid date'; + } + } + + @override + Widget build(BuildContext context) { + try { + final contractId = contract.contract_id.toString(); + final contractType = contract.type; + final state = contract.state; + final twinId = contract.twin_id.toString(); + final createdAt = _formatDate(contract.created_at); + + List detailRows = [ + _buildDetailRow('Contract ID', contractId, context), + const Divider(), + _buildDetailRow('Type', contractType, context), + const Divider(), + _buildDetailRow('Status', state, context, isStatus: true), + const Divider(), + _buildDetailRow('Twin ID', twinId, context), + const Divider(), + _buildDetailRow('Created', createdAt, context), + ]; + + if (contract.details != null && contract.details is Map) { + final details = contract.details as Map; + final lowerType = contractType.toLowerCase(); + + if (lowerType == 'name') { + if (details.containsKey('name')) { + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('name', details['name'].toString(), context)); + } + } else if (lowerType == 'rent') { + if (details.containsKey('nodeId')) { + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('nodeId', details['nodeId'].toString(), context)); + } + if (details.containsKey('farm_name')) { + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('farm_name', details['farm_name'].toString(), context)); + } + if (details.containsKey('farm_id')) { + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('farm_id', details['farm_id'].toString(), context)); + } + } else if (lowerType == 'node') { + if (details.containsKey('nodeId')) { + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('nodeId', details['nodeId'].toString(), context)); + } + if (details.containsKey('deployment_hash')) { + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('deployment_hash', details['deployment_hash'].toString(), context)); + } + if (details.containsKey('number_of_public_ips')) { + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('number_of_public_ips', details['number_of_public_ips'].toString(), context)); + } + if (details.containsKey('farm_name')) { + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('farm_name', details['farm_name'].toString(), context)); + } + if (details.containsKey('farm_id')) { + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('farm_id', details['farm_id'].toString(), context)); + } + + if (details.containsKey('deployment_data') && + details['deployment_data'] is String && + details['deployment_data'].isNotEmpty) { + try { + final Map decoded = json.decode(details['deployment_data']); + decoded.forEach((deployKey, deployValue) { + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow(deployKey, deployValue.toString(), context)); + }); + } catch (e) { + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('deployment_data', details['deployment_data'].toString(), context)); + } + } + } + } + + detailRows.add(const Divider()); + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: detailRows, + ), + ); + } catch (e) { + logger.e('Error building contract detail view: $e'); + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Error displaying contract', + style: Theme.of(context).textTheme.titleMedium!.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + const SizedBox(height: 8), + Text( + e.toString(), + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ), + ); + } + } + + Widget _buildDetailRow(String label, String value, BuildContext context, {bool isStatus = false}) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: Theme.of(context).textTheme.titleMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold)), + const SizedBox(height: 6), + isStatus && (value.toLowerCase() == 'created' || + value.toLowerCase() == 'deleted' || + value.toLowerCase() == 'graceperiod') + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + border: Border.all( + color: _getStatusColor(value, context), + ), + borderRadius: BorderRadius.circular(20), + ), + child: Text(_capitalizeFirstLetter(value.toLowerCase()), + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith( + color: _getStatusColor(value, context), + )), + ) + : Text(value, + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + color: + Theme.of(context).colorScheme.onSurface)), + ], + ), + ), + ], + ), + ); + } + + Color _getStatusColor(String status, BuildContext context) { + final lowerStatus = status.toLowerCase(); + if (lowerStatus == 'created') { + return Theme.of(context).colorScheme.primary; + } else if (lowerStatus == 'deleted') { + return Theme.of(context).colorScheme.error; + } else if (lowerStatus == 'graceperiod') { + return Colors.orange; + } else { + return Theme.of(context).colorScheme.onSurface; + } + } + + String _capitalizeFirstLetter(String text) { + if (text.isEmpty) return text; + return text[0].toUpperCase() + text.substring(1); + } +} From cbc097706bdfc130acda7cfb569ed1870b8e6668 Mon Sep 17 00:00:00 2001 From: zaelgohary Date: Sun, 1 Jun 2025 16:38:42 +0300 Subject: [PATCH 2/6] Refactor contracts, update grid proxy ref, edit state in getContractsByTwinId --- app/lib/screens/wallets/contracts.dart | 161 ++++++++++-------------- app/lib/services/gridproxy_service.dart | 2 +- app/pubspec.lock | 2 +- 3 files changed, 69 insertions(+), 96 deletions(-) diff --git a/app/lib/screens/wallets/contracts.dart b/app/lib/screens/wallets/contracts.dart index fbba8aba3..7cd6284f6 100644 --- a/app/lib/screens/wallets/contracts.dart +++ b/app/lib/screens/wallets/contracts.dart @@ -7,14 +7,14 @@ import 'package:threebotlogin/services/gridproxy_service.dart'; import 'package:threebotlogin/services/tfchain_service.dart'; import 'package:gridproxy_client/models/contracts.dart'; import 'package:threebotlogin/widgets/wallets/contract_details.dart'; -import 'dart:convert'; class WalletContractsWidget extends ConsumerStatefulWidget { const WalletContractsWidget({super.key, required this.wallet}); final Wallet wallet; @override - ConsumerState createState() => _WalletContractsWidgetState(); + ConsumerState createState() => + _WalletContractsWidgetState(); } class _WalletContractsWidgetState extends ConsumerState { @@ -33,42 +33,9 @@ class _WalletContractsWidgetState extends ConsumerState { loading = true; failed = false; }); - try { - int? twinId; - try { - twinId = await getTwinId(widget.wallet.tfchainSecret); - logger.i('Found twin ID: $twinId for wallet: ${widget.wallet.tfchainAddress}'); - } catch (e) { - logger.w('Could not get twin ID: $e'); - } - - if (twinId != null) { - try { - final contractsList = await getContractsByTwinId(twinId); - contracts = contractsList.cast(); - logger.i('Loaded ${contracts.length} contracts for twin ID: $twinId'); - } catch (e) { - logger.w('Error fetching contracts by twin ID: $e'); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: ${e.toString().split(': ').last}'), - duration: const Duration(seconds: 3), - ), - ); - } - } - } else { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Could not find a twin ID for this wallet. No contracts can be displayed.'), - duration: Duration(seconds: 5), - ), - ); - } - } + final twinId = await getTwinId(widget.wallet.tfchainSecret); + contracts = await getContractsByTwinId(twinId); } catch (e) { logger.e('Failed to load contracts: $e'); setState(() { @@ -77,7 +44,7 @@ class _WalletContractsWidgetState extends ConsumerState { if (context.mounted) { final loadingContractsFailure = SnackBar( content: Text( - 'Failed to load contracts: ${e.toString().split(': ').last}', + 'Failed to load contracts', style: Theme.of(context) .textTheme .bodyMedium! @@ -98,7 +65,7 @@ class _WalletContractsWidgetState extends ConsumerState { @override Widget build(BuildContext context) { Widget content; - + if (loading) { content = Center( child: Column( @@ -124,8 +91,10 @@ class _WalletContractsWidgetState extends ConsumerState { const SizedBox(height: 15), Text( 'Failed to load contracts', - style: Theme.of(context).textTheme.bodyLarge!.copyWith( - color: Theme.of(context).colorScheme.error), + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith(color: Theme.of(context).colorScheme.error), ), const SizedBox(height: 15), ElevatedButton.icon( @@ -144,14 +113,17 @@ class _WalletContractsWidgetState extends ConsumerState { Icon( Icons.description_outlined, size: 64, - color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.5), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withOpacity(0.5), ), const SizedBox(height: 16), Text( 'No contracts found for this wallet', style: Theme.of(context).textTheme.titleMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), + color: Theme.of(context).colorScheme.onSurface, + ), textAlign: TextAlign.center, ), const SizedBox(height: 8), @@ -160,8 +132,8 @@ class _WalletContractsWidgetState extends ConsumerState { child: Text( 'Contracts will appear here when you deploy workloads on the ThreeFold Grid', style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), textAlign: TextAlign.center, ), ), @@ -181,36 +153,21 @@ class _WalletContractsWidgetState extends ConsumerState { ), ); } - + return content; } - + Widget _buildContractListItem(BuildContext context, ContractInfo contract) { - final contractId = contract.contract_id.toString(); + final contractId = contract.contract_id; final contractType = contract.type; final state = contract.state; - + String name = ''; - - if (contract.details != null && contract.details is Map) { - final details = contract.details as Map; - - if (contractType.toLowerCase() == 'name' && details.containsKey('name')) { - name = details['name']?.toString() ?? ''; - } - else if (contractType.toLowerCase() == 'node' && - details.containsKey('deployment_data') && - details['deployment_data'] is String && - details['deployment_data'].isNotEmpty) { - try { - final Map decoded = json.decode(details['deployment_data']); - name = decoded['name']?.toString() ?? ''; - } catch (e) { - logger.d('Could not parse deployment_data JSON: $e'); - } - } + + if (contract.details != null && contractType.toLowerCase() == 'name') { + name = (contract.details as Map)['name']; } - + // Get icon based on contract type IconData typeIcon = Icons.description_outlined; if (contractType.toLowerCase() == 'name') { @@ -220,7 +177,7 @@ class _WalletContractsWidgetState extends ConsumerState { } else if (contractType.toLowerCase() == 'rent') { typeIcon = Icons.storage_outlined; } - + return Card( margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), elevation: 2, @@ -267,27 +224,38 @@ class _WalletContractsWidgetState extends ConsumerState { children: [ Text( 'Contract $contractId', - style: Theme.of(context).textTheme.titleMedium!.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Row( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, + color: Theme.of(context) + .colorScheme + .surfaceVariant, borderRadius: BorderRadius.circular(4), ), child: Text( contractType, - style: Theme.of(context).textTheme.bodySmall!.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + fontWeight: FontWeight.w500, + ), ), ), if (name.isNotEmpty) ...[ @@ -295,9 +263,14 @@ class _WalletContractsWidgetState extends ConsumerState { Expanded( child: Text( name, - style: Theme.of(context).textTheme.bodySmall!.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), overflow: TextOverflow.ellipsis, ), ), @@ -320,9 +293,9 @@ class _WalletContractsWidgetState extends ConsumerState { Text( 'View Details', style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w500, - ), + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), ), Icon( Icons.arrow_forward_ios, @@ -338,20 +311,20 @@ class _WalletContractsWidgetState extends ConsumerState { ), ); } - + Widget _buildStatusBadge(BuildContext context, String status) { final lowerStatus = status.toLowerCase(); Color backgroundColor; Color textColor; - + if (lowerStatus == 'created') { backgroundColor = Theme.of(context).colorScheme.primaryContainer; - textColor = Theme.of(context).colorScheme.onPrimary; + textColor = Theme.of(context).colorScheme.onPrimaryContainer; } else { backgroundColor = Theme.of(context).colorScheme.warningContainer; textColor = Theme.of(context).colorScheme.warning; } - + return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( @@ -361,13 +334,13 @@ class _WalletContractsWidgetState extends ConsumerState { child: Text( _capitalizeFirstLetter(lowerStatus), style: Theme.of(context).textTheme.labelSmall!.copyWith( - color: textColor, - fontWeight: FontWeight.bold, - ), + color: textColor, + fontWeight: FontWeight.bold, + ), ), ); } - + String _capitalizeFirstLetter(String text) { if (text.isEmpty) return text; return text[0].toUpperCase() + text.substring(1); diff --git a/app/lib/services/gridproxy_service.dart b/app/lib/services/gridproxy_service.dart index 6da27c9dd..13d371546 100644 --- a/app/lib/services/gridproxy_service.dart +++ b/app/lib/services/gridproxy_service.dart @@ -24,7 +24,7 @@ Future> getContractsByTwinId(int twinId) async { final client = GridProxyClient(gridproxyUrl); final contracts = - await client.contracts.list(ContractInfoQueryParams(twin_id: twinId, state: ContractState.GracePeriod)); + await client.contracts.list(ContractInfoQueryParams(twin_id: twinId, state: [ContractState.Created, ContractState.GracePeriod])); return contracts; } catch (e) { throw Exception('Error fetching contracts: $e'); diff --git a/app/pubspec.lock b/app/pubspec.lock index 18212cc15..d2e1d3eb6 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -811,7 +811,7 @@ packages: description: path: "packages/gridproxy_client" ref: development - resolved-ref: "4ef4d3bc2550017d987f27fd8c2264854c5cf683" + resolved-ref: a2e6e9d8a560d93474c77edacb0b8e8f04187cef url: "https://github.com/threefoldtech/tfgrid-sdk-dart" source: git version: "1.0.0" From 215b4f1a599d59dd9b3c857b09ef41df37ac5fd4 Mon Sep 17 00:00:00 2001 From: zaelgohary Date: Mon, 2 Jun 2025 13:11:21 +0300 Subject: [PATCH 3/6] Update grid proxy ref, create contract helpers, refactor contracts & contract details, fix name contract type err --- app/lib/helpers/contract_helpers.dart | 91 ++++++++++ app/lib/screens/wallets/contracts.dart | 113 +++--------- app/lib/widgets/wallets/contract_details.dart | 163 ++---------------- app/pubspec.lock | 2 +- 4 files changed, 134 insertions(+), 235 deletions(-) create mode 100644 app/lib/helpers/contract_helpers.dart diff --git a/app/lib/helpers/contract_helpers.dart b/app/lib/helpers/contract_helpers.dart new file mode 100644 index 000000000..d392f44b7 --- /dev/null +++ b/app/lib/helpers/contract_helpers.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:gridproxy_client/models/contracts.dart'; +// ignore: depend_on_referenced_packages +import 'package:intl/intl.dart'; +import 'package:threebotlogin/main.dart'; + +String capitalizeFirstLetter(String text) { + if (text.isEmpty) return text; + + if (text.toLowerCase() == 'graceperiod') { + return 'Grace Period'; + } else { + return 'Created'; + } +} + +String formatDate(int timestamp) { + if (timestamp <= 0) return 'N/A'; + try { + final date = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); + return DateFormat('MMM d, yyyy').format(date); + } catch (e) { + return 'Invalid date'; + } +} + +Color getStatusColor(String status, BuildContext context) { + final lowerStatus = status.toLowerCase(); + switch (lowerStatus) { + case 'graceperiod': + return Theme.of(context).colorScheme.warning; + default: + return Theme.of(context).colorScheme.primary; + } +} + +Map getStatusBadgeColors(String status, BuildContext context) { + final lowerStatus = status.toLowerCase(); + if (lowerStatus == 'created') { + return { + 'background': Theme.of(context).colorScheme.primaryContainer, + 'text': Theme.of(context).colorScheme.onPrimaryContainer, + }; + } else { + return { + 'background': Theme.of(context).colorScheme.warningContainer, + 'text': Theme.of(context).colorScheme.onWarningContainer, + }; + } +} + +Map extractContractDetails(ContractInfo contract) { + Map result = {}; + result['Contract ID'] = contract.contract_id.toString(); + result['Type'] = contract.type; + result['Status'] = contract.state; + result['Twin ID'] = contract.twin_id.toString(); + result['Created'] = formatDate(contract.created_at); + if (contract.details is NameContract) { + result['Name'] = (contract.details as NameContract).name; + } else if (contract.details is RentContract) { + result['Node ID'] = (contract.details as RentContract).nodeId.toString(); + result['Farm Name'] = (contract.details as RentContract).farm_name; + result['Farm ID'] = (contract.details as RentContract).farm_id.toString(); + } else if (contract.details is NodeContract) { + result['Node ID'] = (contract.details as NodeContract).nodeId.toString(); + result['Deployment Hash'] = (contract.details as NodeContract).deployment_hash; + result['Public IPs'] = (contract.details as NodeContract).number_of_public_ips.toString(); + } + + return result; +} + +Widget buildStatusBadge(BuildContext context, String status) { + final colors = getStatusBadgeColors(status, context); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colors['background'], + borderRadius: BorderRadius.circular(16), + ), + child: Text( + capitalizeFirstLetter(status), + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: colors['text'], + fontWeight: FontWeight.bold, + ), + ), + ); +} diff --git a/app/lib/screens/wallets/contracts.dart b/app/lib/screens/wallets/contracts.dart index 7cd6284f6..35b4aa0cf 100644 --- a/app/lib/screens/wallets/contracts.dart +++ b/app/lib/screens/wallets/contracts.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:threebotlogin/helpers/logger.dart'; -import 'package:threebotlogin/main.dart'; import 'package:threebotlogin/models/wallet.dart'; import 'package:threebotlogin/services/gridproxy_service.dart'; import 'package:threebotlogin/services/tfchain_service.dart'; import 'package:gridproxy_client/models/contracts.dart'; import 'package:threebotlogin/widgets/wallets/contract_details.dart'; +import 'package:threebotlogin/helpers/contract_helpers.dart'; class WalletContractsWidget extends ConsumerStatefulWidget { const WalletContractsWidget({super.key, required this.wallet}); @@ -64,10 +64,8 @@ class _WalletContractsWidgetState extends ConsumerState { @override Widget build(BuildContext context) { - Widget content; - if (loading) { - content = Center( + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -82,8 +80,10 @@ class _WalletContractsWidgetState extends ConsumerState { ], ), ); - } else if (failed) { - content = Center( + } + + if (failed) { + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -105,8 +105,10 @@ class _WalletContractsWidgetState extends ConsumerState { ], ), ); - } else if (contracts.isEmpty) { - content = Center( + } + + if (contracts.isEmpty) { + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -140,21 +142,19 @@ class _WalletContractsWidgetState extends ConsumerState { ], ), ); - } else { - content = RefreshIndicator( - onRefresh: _loadContracts, - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8.0), - itemCount: contracts.length, - itemBuilder: (context, index) { - final contract = contracts[index]; - return _buildContractListItem(context, contract); - }, - ), - ); } - return content; + return RefreshIndicator( + onRefresh: _loadContracts, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8.0), + itemCount: contracts.length, + itemBuilder: (context, index) { + final contract = contracts[index]; + return _buildContractListItem(context, contract); + }, + ), + ); } Widget _buildContractListItem(BuildContext context, ContractInfo contract) { @@ -162,22 +162,6 @@ class _WalletContractsWidgetState extends ConsumerState { final contractType = contract.type; final state = contract.state; - String name = ''; - - if (contract.details != null && contractType.toLowerCase() == 'name') { - name = (contract.details as Map)['name']; - } - - // Get icon based on contract type - IconData typeIcon = Icons.description_outlined; - if (contractType.toLowerCase() == 'name') { - typeIcon = Icons.dns_outlined; - } else if (contractType.toLowerCase() == 'node') { - typeIcon = Icons.computer_outlined; - } else if (contractType.toLowerCase() == 'rent') { - typeIcon = Icons.storage_outlined; - } - return Card( margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), elevation: 2, @@ -212,7 +196,7 @@ class _WalletContractsWidgetState extends ConsumerState { borderRadius: BorderRadius.circular(12), ), child: Icon( - typeIcon, + Icons.description, color: Theme.of(context).colorScheme.onSecondaryContainer, size: 24, ), @@ -258,29 +242,12 @@ class _WalletContractsWidgetState extends ConsumerState { ), ), ), - if (name.isNotEmpty) ...[ - const SizedBox(width: 8), - Expanded( - child: Text( - name, - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], ], ), ], ), ), - _buildStatusBadge(context, state), + buildStatusBadge(context, state), ], ), const SizedBox(height: 12), @@ -311,40 +278,6 @@ class _WalletContractsWidgetState extends ConsumerState { ), ); } - - Widget _buildStatusBadge(BuildContext context, String status) { - final lowerStatus = status.toLowerCase(); - Color backgroundColor; - Color textColor; - - if (lowerStatus == 'created') { - backgroundColor = Theme.of(context).colorScheme.primaryContainer; - textColor = Theme.of(context).colorScheme.onPrimaryContainer; - } else { - backgroundColor = Theme.of(context).colorScheme.warningContainer; - textColor = Theme.of(context).colorScheme.warning; - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(16), - ), - child: Text( - _capitalizeFirstLetter(lowerStatus), - style: Theme.of(context).textTheme.labelSmall!.copyWith( - color: textColor, - fontWeight: FontWeight.bold, - ), - ), - ); - } - - String _capitalizeFirstLetter(String text) { - if (text.isEmpty) return text; - return text[0].toUpperCase() + text.substring(1); - } } class ContractDetailScreen extends StatelessWidget { diff --git a/app/lib/widgets/wallets/contract_details.dart b/app/lib/widgets/wallets/contract_details.dart index 0de5bbb13..30b95463e 100644 --- a/app/lib/widgets/wallets/contract_details.dart +++ b/app/lib/widgets/wallets/contract_details.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; import 'package:gridproxy_client/models/contracts.dart'; -// ignore: depend_on_referenced_packages -import 'package:intl/intl.dart'; import 'package:threebotlogin/helpers/logger.dart'; -import 'dart:convert'; +import 'package:threebotlogin/helpers/contract_helpers.dart'; class ContractDetails extends StatelessWidget { final ContractInfo contract; @@ -13,101 +11,17 @@ class ContractDetails extends StatelessWidget { required this.contract, }); - String _formatDate(int timestamp) { - if (timestamp <= 0) return 'N/A'; - - try { - final date = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); - return DateFormat('MMM d, yyyy').format(date); - } catch (e) { - return 'Invalid date'; - } - } - @override Widget build(BuildContext context) { try { - final contractId = contract.contract_id.toString(); - final contractType = contract.type; - final state = contract.state; - final twinId = contract.twin_id.toString(); - final createdAt = _formatDate(contract.created_at); - - List detailRows = [ - _buildDetailRow('Contract ID', contractId, context), - const Divider(), - _buildDetailRow('Type', contractType, context), - const Divider(), - _buildDetailRow('Status', state, context, isStatus: true), - const Divider(), - _buildDetailRow('Twin ID', twinId, context), - const Divider(), - _buildDetailRow('Created', createdAt, context), - ]; - - if (contract.details != null && contract.details is Map) { - final details = contract.details as Map; - final lowerType = contractType.toLowerCase(); - - if (lowerType == 'name') { - if (details.containsKey('name')) { - detailRows.add(const Divider()); - detailRows.add(_buildDetailRow('name', details['name'].toString(), context)); - } - } else if (lowerType == 'rent') { - if (details.containsKey('nodeId')) { - detailRows.add(const Divider()); - detailRows.add(_buildDetailRow('nodeId', details['nodeId'].toString(), context)); - } - if (details.containsKey('farm_name')) { - detailRows.add(const Divider()); - detailRows.add(_buildDetailRow('farm_name', details['farm_name'].toString(), context)); - } - if (details.containsKey('farm_id')) { - detailRows.add(const Divider()); - detailRows.add(_buildDetailRow('farm_id', details['farm_id'].toString(), context)); - } - } else if (lowerType == 'node') { - if (details.containsKey('nodeId')) { - detailRows.add(const Divider()); - detailRows.add(_buildDetailRow('nodeId', details['nodeId'].toString(), context)); - } - if (details.containsKey('deployment_hash')) { - detailRows.add(const Divider()); - detailRows.add(_buildDetailRow('deployment_hash', details['deployment_hash'].toString(), context)); - } - if (details.containsKey('number_of_public_ips')) { - detailRows.add(const Divider()); - detailRows.add(_buildDetailRow('number_of_public_ips', details['number_of_public_ips'].toString(), context)); - } - if (details.containsKey('farm_name')) { - detailRows.add(const Divider()); - detailRows.add(_buildDetailRow('farm_name', details['farm_name'].toString(), context)); - } - if (details.containsKey('farm_id')) { - detailRows.add(const Divider()); - detailRows.add(_buildDetailRow('farm_id', details['farm_id'].toString(), context)); - } - - if (details.containsKey('deployment_data') && - details['deployment_data'] is String && - details['deployment_data'].isNotEmpty) { - try { - final Map decoded = json.decode(details['deployment_data']); - decoded.forEach((deployKey, deployValue) { - detailRows.add(const Divider()); - detailRows.add(_buildDetailRow(deployKey, deployValue.toString(), context)); - }); - } catch (e) { - detailRows.add(const Divider()); - detailRows.add(_buildDetailRow('deployment_data', details['deployment_data'].toString(), context)); - } - } - } + List detailRows = []; + final contractDetails = extractContractDetails(contract); + for (final entry in contractDetails.entries) { + detailRows.add(_buildDetailRow(entry.key, entry.value, context, isStatus: entry.key == 'Status')); + if (entry.value != contractDetails.entries.last.value) detailRows.add(const Divider()); } - - detailRows.add(const Divider()); - + + return Padding( padding: const EdgeInsets.all(16.0), child: Column( @@ -125,23 +39,24 @@ class ContractDetails extends StatelessWidget { Text( 'Error displaying contract', style: Theme.of(context).textTheme.titleMedium!.copyWith( - color: Theme.of(context).colorScheme.error, - ), + color: Theme.of(context).colorScheme.error, + ), ), const SizedBox(height: 8), Text( e.toString(), style: Theme.of(context).textTheme.bodySmall!.copyWith( - color: Theme.of(context).colorScheme.error, - ), + color: Theme.of(context).colorScheme.error, + ), ), ], ), ); } } - - Widget _buildDetailRow(String label, String value, BuildContext context, {bool isStatus = false}) { + + Widget _buildDetailRow(String label, String value, BuildContext context, + {bool isStatus = false}) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Row( @@ -156,33 +71,11 @@ class ContractDetails extends StatelessWidget { color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold)), const SizedBox(height: 6), - isStatus && (value.toLowerCase() == 'created' || - value.toLowerCase() == 'deleted' || - value.toLowerCase() == 'graceperiod') - ? Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), - decoration: BoxDecoration( - border: Border.all( - color: _getStatusColor(value, context), - ), - borderRadius: BorderRadius.circular(20), - ), - child: Text(_capitalizeFirstLetter(value.toLowerCase()), - style: Theme.of(context) - .textTheme - .bodyMedium! - .copyWith( - color: _getStatusColor(value, context), - )), - ) + isStatus + ? buildStatusBadge(context, value) : Text(value, - style: Theme.of(context) - .textTheme - .bodyLarge! - .copyWith( - color: - Theme.of(context).colorScheme.onSurface)), + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface)), ], ), ), @@ -190,22 +83,4 @@ class ContractDetails extends StatelessWidget { ), ); } - - Color _getStatusColor(String status, BuildContext context) { - final lowerStatus = status.toLowerCase(); - if (lowerStatus == 'created') { - return Theme.of(context).colorScheme.primary; - } else if (lowerStatus == 'deleted') { - return Theme.of(context).colorScheme.error; - } else if (lowerStatus == 'graceperiod') { - return Colors.orange; - } else { - return Theme.of(context).colorScheme.onSurface; - } - } - - String _capitalizeFirstLetter(String text) { - if (text.isEmpty) return text; - return text[0].toUpperCase() + text.substring(1); - } } diff --git a/app/pubspec.lock b/app/pubspec.lock index d2e1d3eb6..93473e544 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -811,7 +811,7 @@ packages: description: path: "packages/gridproxy_client" ref: development - resolved-ref: a2e6e9d8a560d93474c77edacb0b8e8f04187cef + resolved-ref: "278f1b6ba113e9bed8b44f0f014d0b24e2ddc7e4" url: "https://github.com/threefoldtech/tfgrid-sdk-dart" source: git version: "1.0.0" From 9e7f33aac1420634d7835185f26529e5ebe64d66 Mon Sep 17 00:00:00 2001 From: zaelgohary Date: Mon, 2 Jun 2025 15:16:04 +0300 Subject: [PATCH 4/6] Handle no internet connection & timeout --- app/lib/screens/wallets/contracts.dart | 82 +++++++++++++++++++------- 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/app/lib/screens/wallets/contracts.dart b/app/lib/screens/wallets/contracts.dart index 35b4aa0cf..4d639d1a4 100644 --- a/app/lib/screens/wallets/contracts.dart +++ b/app/lib/screens/wallets/contracts.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:threebotlogin/helpers/logger.dart'; @@ -7,6 +9,7 @@ import 'package:threebotlogin/services/tfchain_service.dart'; import 'package:gridproxy_client/models/contracts.dart'; import 'package:threebotlogin/widgets/wallets/contract_details.dart'; import 'package:threebotlogin/helpers/contract_helpers.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; class WalletContractsWidget extends ConsumerStatefulWidget { const WalletContractsWidget({super.key, required this.wallet}); @@ -33,35 +36,74 @@ class _WalletContractsWidgetState extends ConsumerState { loading = true; failed = false; }); + try { - final twinId = await getTwinId(widget.wallet.tfchainSecret); - contracts = await getContractsByTwinId(twinId); - } catch (e) { - logger.e('Failed to load contracts: $e'); - setState(() { - failed = true; - }); - if (context.mounted) { - final loadingContractsFailure = SnackBar( - content: Text( - 'Failed to load contracts', - style: Theme.of(context) - .textTheme - .bodyMedium! - .copyWith(color: Theme.of(context).colorScheme.errorContainer), - ), - duration: const Duration(seconds: 3), + final connectivityResult = await (Connectivity().checkConnectivity()); + + if (connectivityResult.contains(ConnectivityResult.none)) { + _handleFailure( + 'No internet connection. Please check your network.', ); - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar(loadingContractsFailure); + return; } - } finally { + + final twinId = await getTwinId(widget.wallet.tfchainSecret).timeout( + const Duration(minutes: 1), + onTimeout: () { + throw TimeoutException('Loading contracts timed out'); + }, + ); + + contracts = await getContractsByTwinId(twinId).timeout( + const Duration(minutes: 1), + onTimeout: () { + throw TimeoutException('Loading contracts timed out'); + }, + ); + setState(() { loading = false; + failed = false; }); + } on TimeoutException catch (e) { + _handleFailure( + 'Loading contracts timed out. Please check your network.', + error: e, + ); + } catch (e) { + _handleFailure( + 'Failed to load contracts. Please try again.', + error: e, + ); } } + void _handleFailure(String userMessage, {Object? error}) { + if (error != null) { + logger.e('Load contracts failed', error: error); + } + + if (mounted) { + final errorSnackbar = SnackBar( + content: Text( + userMessage, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Theme.of(context).colorScheme.errorContainer), + ), + duration: const Duration(seconds: 3), + ); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar(errorSnackbar); + } + + setState(() { + loading = false; + failed = true; + }); + } + @override Widget build(BuildContext context) { if (loading) { From 464959723d7135b0f17debbf06b5ad45ba2a3ee7 Mon Sep 17 00:00:00 2001 From: zaelgohary Date: Sun, 22 Jun 2025 12:05:04 +0300 Subject: [PATCH 5/6] Rename capitalizeFirstLetter to formatStatus --- app/lib/helpers/contract_helpers.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/helpers/contract_helpers.dart b/app/lib/helpers/contract_helpers.dart index d392f44b7..4bccb0b1e 100644 --- a/app/lib/helpers/contract_helpers.dart +++ b/app/lib/helpers/contract_helpers.dart @@ -4,7 +4,7 @@ import 'package:gridproxy_client/models/contracts.dart'; import 'package:intl/intl.dart'; import 'package:threebotlogin/main.dart'; -String capitalizeFirstLetter(String text) { +String formatStatus(String text) { if (text.isEmpty) return text; if (text.toLowerCase() == 'graceperiod') { @@ -81,7 +81,7 @@ Widget buildStatusBadge(BuildContext context, String status) { borderRadius: BorderRadius.circular(16), ), child: Text( - capitalizeFirstLetter(status), + formatStatus(status), style: Theme.of(context).textTheme.labelSmall!.copyWith( color: colors['text'], fontWeight: FontWeight.bold, From df2298f1480b835c07460814ca94ef9f481e9ef4 Mon Sep 17 00:00:00 2001 From: zaelgohary Date: Sun, 22 Jun 2025 15:41:09 +0300 Subject: [PATCH 6/6] Remove extractContractDetails --- app/lib/helpers/contract_helpers.dart | 23 ------------ app/lib/widgets/wallets/contract_details.dart | 37 ++++++++++++++++--- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/app/lib/helpers/contract_helpers.dart b/app/lib/helpers/contract_helpers.dart index 4bccb0b1e..f51f2fc8a 100644 --- a/app/lib/helpers/contract_helpers.dart +++ b/app/lib/helpers/contract_helpers.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:gridproxy_client/models/contracts.dart'; // ignore: depend_on_referenced_packages import 'package:intl/intl.dart'; import 'package:threebotlogin/main.dart'; @@ -49,28 +48,6 @@ Map getStatusBadgeColors(String status, BuildContext context) { } } -Map extractContractDetails(ContractInfo contract) { - Map result = {}; - result['Contract ID'] = contract.contract_id.toString(); - result['Type'] = contract.type; - result['Status'] = contract.state; - result['Twin ID'] = contract.twin_id.toString(); - result['Created'] = formatDate(contract.created_at); - if (contract.details is NameContract) { - result['Name'] = (contract.details as NameContract).name; - } else if (contract.details is RentContract) { - result['Node ID'] = (contract.details as RentContract).nodeId.toString(); - result['Farm Name'] = (contract.details as RentContract).farm_name; - result['Farm ID'] = (contract.details as RentContract).farm_id.toString(); - } else if (contract.details is NodeContract) { - result['Node ID'] = (contract.details as NodeContract).nodeId.toString(); - result['Deployment Hash'] = (contract.details as NodeContract).deployment_hash; - result['Public IPs'] = (contract.details as NodeContract).number_of_public_ips.toString(); - } - - return result; -} - Widget buildStatusBadge(BuildContext context, String status) { final colors = getStatusBadgeColors(status, context); diff --git a/app/lib/widgets/wallets/contract_details.dart b/app/lib/widgets/wallets/contract_details.dart index 30b95463e..5fc4c8094 100644 --- a/app/lib/widgets/wallets/contract_details.dart +++ b/app/lib/widgets/wallets/contract_details.dart @@ -14,14 +14,39 @@ class ContractDetails extends StatelessWidget { @override Widget build(BuildContext context) { try { - List detailRows = []; - final contractDetails = extractContractDetails(contract); - for (final entry in contractDetails.entries) { - detailRows.add(_buildDetailRow(entry.key, entry.value, context, isStatus: entry.key == 'Status')); - if (entry.value != contractDetails.entries.last.value) detailRows.add(const Divider()); + List detailRows = []; + + detailRows.add(_buildDetailRow('Contract ID', contract.contract_id.toString(), context)); + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('Type', contract.type, context)); + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('Status', contract.state, context, isStatus: true)); + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('Twin ID', contract.twin_id.toString(), context)); + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('Created', formatDate(contract.created_at), context)); + + if (contract.details is NameContract) { + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('Name', (contract.details as NameContract).name, context)); + } else if (contract.details is RentContract) { + final rentContract = contract.details as RentContract; + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('Node ID', rentContract.nodeId.toString(), context)); + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('Farm Name', rentContract.farm_name, context)); + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('Farm ID', rentContract.farm_id.toString(), context)); + } else if (contract.details is NodeContract) { + final nodeContract = contract.details as NodeContract; + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('Node ID', nodeContract.nodeId.toString(), context)); + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('Deployment Hash', nodeContract.deployment_hash, context)); + detailRows.add(const Divider()); + detailRows.add(_buildDetailRow('Public IPs', nodeContract.number_of_public_ips.toString(), context)); } - return Padding( padding: const EdgeInsets.all(16.0), child: Column(