diff --git a/app/lib/helpers/contract_helpers.dart b/app/lib/helpers/contract_helpers.dart new file mode 100644 index 00000000..f51f2fc8 --- /dev/null +++ b/app/lib/helpers/contract_helpers.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +// ignore: depend_on_referenced_packages +import 'package:intl/intl.dart'; +import 'package:threebotlogin/main.dart'; + +String formatStatus(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, + }; + } +} + +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( + formatStatus(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 new file mode 100644 index 00000000..4d639d1a --- /dev/null +++ b/app/lib/screens/wallets/contracts.dart @@ -0,0 +1,347 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:threebotlogin/helpers/logger.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'; +import 'package:connectivity_plus/connectivity_plus.dart'; + +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 { + final connectivityResult = await (Connectivity().checkConnectivity()); + + if (connectivityResult.contains(ConnectivityResult.none)) { + _handleFailure( + 'No internet connection. Please check your network.', + ); + return; + } + + 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) { + return 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), + ), + ], + ), + ); + } + + if (failed) { + return 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, + ), + ], + ), + ); + } + + if (contracts.isEmpty) { + return 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, + ), + ), + ], + ), + ); + } + + 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) { + final contractId = contract.contract_id; + final contractType = contract.type; + final state = contract.state; + + 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( + Icons.description, + 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, + ), + ), + ), + ], + ), + ], + ), + ), + 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, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +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 237232bc..25342077 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 1b215d32..13d37154 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.Created, 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 f5556db6..6f4b13ba 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 00000000..5fc4c809 --- /dev/null +++ b/app/lib/widgets/wallets/contract_details.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:gridproxy_client/models/contracts.dart'; +import 'package:threebotlogin/helpers/logger.dart'; +import 'package:threebotlogin/helpers/contract_helpers.dart'; + +class ContractDetails extends StatelessWidget { + final ContractInfo contract; + + const ContractDetails({ + super.key, + required this.contract, + }); + + @override + Widget build(BuildContext context) { + try { + 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( + 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 + ? buildStatusBadge(context, value) + : Text(value, + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface)), + ], + ), + ), + ], + ), + ); + } +} diff --git a/app/pubspec.lock b/app/pubspec.lock index 18212cc1..93473e54 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: "278f1b6ba113e9bed8b44f0f014d0b24e2ddc7e4" url: "https://github.com/threefoldtech/tfgrid-sdk-dart" source: git version: "1.0.0"