Skip to content

Commit 0e39d6d

Browse files
committed
Add contracts screen & include it in wallet bottom nav
1 parent 267b783 commit 0e39d6d

5 files changed

Lines changed: 635 additions & 1 deletion

File tree

Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3+
import 'package:threebotlogin/helpers/logger.dart';
4+
import 'package:threebotlogin/main.dart';
5+
import 'package:threebotlogin/models/wallet.dart';
6+
import 'package:threebotlogin/services/gridproxy_service.dart';
7+
import 'package:threebotlogin/services/tfchain_service.dart';
8+
import 'package:gridproxy_client/models/contracts.dart';
9+
import 'package:threebotlogin/widgets/wallets/contract_details.dart';
10+
import 'dart:convert';
11+
12+
class WalletContractsWidget extends ConsumerStatefulWidget {
13+
const WalletContractsWidget({super.key, required this.wallet});
14+
final Wallet wallet;
15+
16+
@override
17+
ConsumerState<WalletContractsWidget> createState() => _WalletContractsWidgetState();
18+
}
19+
20+
class _WalletContractsWidgetState extends ConsumerState<WalletContractsWidget> {
21+
bool loading = true;
22+
bool failed = false;
23+
List<ContractInfo> contracts = [];
24+
25+
@override
26+
void initState() {
27+
super.initState();
28+
_loadContracts();
29+
}
30+
31+
Future<void> _loadContracts() async {
32+
setState(() {
33+
loading = true;
34+
failed = false;
35+
});
36+
37+
try {
38+
int? twinId;
39+
try {
40+
twinId = await getTwinId(widget.wallet.tfchainSecret);
41+
logger.i('Found twin ID: $twinId for wallet: ${widget.wallet.tfchainAddress}');
42+
} catch (e) {
43+
logger.w('Could not get twin ID: $e');
44+
}
45+
46+
if (twinId != null) {
47+
try {
48+
final contractsList = await getContractsByTwinId(twinId);
49+
contracts = contractsList.cast<ContractInfo>();
50+
logger.i('Loaded ${contracts.length} contracts for twin ID: $twinId');
51+
} catch (e) {
52+
logger.w('Error fetching contracts by twin ID: $e');
53+
if (context.mounted) {
54+
ScaffoldMessenger.of(context).showSnackBar(
55+
SnackBar(
56+
content: Text('Error: ${e.toString().split(': ').last}'),
57+
duration: const Duration(seconds: 3),
58+
),
59+
);
60+
}
61+
}
62+
} else {
63+
if (context.mounted) {
64+
ScaffoldMessenger.of(context).showSnackBar(
65+
const SnackBar(
66+
content: Text('Could not find a twin ID for this wallet. No contracts can be displayed.'),
67+
duration: Duration(seconds: 5),
68+
),
69+
);
70+
}
71+
}
72+
} catch (e) {
73+
logger.e('Failed to load contracts: $e');
74+
setState(() {
75+
failed = true;
76+
});
77+
if (context.mounted) {
78+
final loadingContractsFailure = SnackBar(
79+
content: Text(
80+
'Failed to load contracts: ${e.toString().split(': ').last}',
81+
style: Theme.of(context)
82+
.textTheme
83+
.bodyMedium!
84+
.copyWith(color: Theme.of(context).colorScheme.errorContainer),
85+
),
86+
duration: const Duration(seconds: 3),
87+
);
88+
ScaffoldMessenger.of(context).clearSnackBars();
89+
ScaffoldMessenger.of(context).showSnackBar(loadingContractsFailure);
90+
}
91+
} finally {
92+
setState(() {
93+
loading = false;
94+
});
95+
}
96+
}
97+
98+
@override
99+
Widget build(BuildContext context) {
100+
Widget content;
101+
102+
if (loading) {
103+
content = Center(
104+
child: Column(
105+
mainAxisAlignment: MainAxisAlignment.center,
106+
children: [
107+
const CircularProgressIndicator(),
108+
const SizedBox(height: 15),
109+
Text(
110+
'Loading Contracts...',
111+
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
112+
color: Theme.of(context).colorScheme.onSurface,
113+
fontWeight: FontWeight.bold),
114+
),
115+
],
116+
),
117+
);
118+
} else if (failed) {
119+
content = Center(
120+
child: Column(
121+
mainAxisAlignment: MainAxisAlignment.center,
122+
children: [
123+
const Icon(Icons.error_outline, size: 48),
124+
const SizedBox(height: 15),
125+
Text(
126+
'Failed to load contracts',
127+
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
128+
color: Theme.of(context).colorScheme.error),
129+
),
130+
const SizedBox(height: 15),
131+
ElevatedButton.icon(
132+
icon: const Icon(Icons.refresh),
133+
label: const Text('Try Again'),
134+
onPressed: _loadContracts,
135+
),
136+
],
137+
),
138+
);
139+
} else if (contracts.isEmpty) {
140+
content = Center(
141+
child: Column(
142+
mainAxisAlignment: MainAxisAlignment.center,
143+
children: [
144+
Icon(
145+
Icons.description_outlined,
146+
size: 64,
147+
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.5),
148+
),
149+
const SizedBox(height: 16),
150+
Text(
151+
'No contracts found for this wallet',
152+
style: Theme.of(context).textTheme.titleMedium!.copyWith(
153+
color: Theme.of(context).colorScheme.onSurface,
154+
),
155+
textAlign: TextAlign.center,
156+
),
157+
const SizedBox(height: 8),
158+
Padding(
159+
padding: const EdgeInsets.symmetric(horizontal: 32.0),
160+
child: Text(
161+
'Contracts will appear here when you deploy workloads on the ThreeFold Grid',
162+
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
163+
color: Theme.of(context).colorScheme.onSurfaceVariant,
164+
),
165+
textAlign: TextAlign.center,
166+
),
167+
),
168+
],
169+
),
170+
);
171+
} else {
172+
content = RefreshIndicator(
173+
onRefresh: _loadContracts,
174+
child: ListView.builder(
175+
padding: const EdgeInsets.symmetric(vertical: 8.0),
176+
itemCount: contracts.length,
177+
itemBuilder: (context, index) {
178+
final contract = contracts[index];
179+
return _buildContractListItem(context, contract);
180+
},
181+
),
182+
);
183+
}
184+
185+
return content;
186+
}
187+
188+
Widget _buildContractListItem(BuildContext context, ContractInfo contract) {
189+
final contractId = contract.contract_id.toString();
190+
final contractType = contract.type;
191+
final state = contract.state;
192+
193+
String name = '';
194+
195+
if (contract.details != null && contract.details is Map) {
196+
final details = contract.details as Map;
197+
198+
if (contractType.toLowerCase() == 'name' && details.containsKey('name')) {
199+
name = details['name']?.toString() ?? '';
200+
}
201+
else if (contractType.toLowerCase() == 'node' &&
202+
details.containsKey('deployment_data') &&
203+
details['deployment_data'] is String &&
204+
details['deployment_data'].isNotEmpty) {
205+
try {
206+
final Map<String, dynamic> decoded = json.decode(details['deployment_data']);
207+
name = decoded['name']?.toString() ?? '';
208+
} catch (e) {
209+
logger.d('Could not parse deployment_data JSON: $e');
210+
}
211+
}
212+
}
213+
214+
// Get icon based on contract type
215+
IconData typeIcon = Icons.description_outlined;
216+
if (contractType.toLowerCase() == 'name') {
217+
typeIcon = Icons.dns_outlined;
218+
} else if (contractType.toLowerCase() == 'node') {
219+
typeIcon = Icons.computer_outlined;
220+
} else if (contractType.toLowerCase() == 'rent') {
221+
typeIcon = Icons.storage_outlined;
222+
}
223+
224+
return Card(
225+
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
226+
elevation: 2,
227+
shape: RoundedRectangleBorder(
228+
borderRadius: BorderRadius.circular(12),
229+
side: BorderSide(
230+
color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.5),
231+
width: 0.5,
232+
),
233+
),
234+
child: InkWell(
235+
onTap: () {
236+
Navigator.of(context).push(
237+
MaterialPageRoute(
238+
builder: (context) => ContractDetailScreen(contract: contract),
239+
),
240+
);
241+
},
242+
borderRadius: BorderRadius.circular(12),
243+
child: Padding(
244+
padding: const EdgeInsets.all(16.0),
245+
child: Column(
246+
crossAxisAlignment: CrossAxisAlignment.start,
247+
children: [
248+
Row(
249+
crossAxisAlignment: CrossAxisAlignment.start,
250+
children: [
251+
Container(
252+
padding: const EdgeInsets.all(10),
253+
decoration: BoxDecoration(
254+
color: Theme.of(context).colorScheme.secondaryContainer,
255+
borderRadius: BorderRadius.circular(12),
256+
),
257+
child: Icon(
258+
typeIcon,
259+
color: Theme.of(context).colorScheme.onSecondaryContainer,
260+
size: 24,
261+
),
262+
),
263+
const SizedBox(width: 12),
264+
Expanded(
265+
child: Column(
266+
crossAxisAlignment: CrossAxisAlignment.start,
267+
children: [
268+
Text(
269+
'Contract $contractId',
270+
style: Theme.of(context).textTheme.titleMedium!.copyWith(
271+
fontWeight: FontWeight.bold,
272+
color: Theme.of(context).colorScheme.onSurface,
273+
),
274+
overflow: TextOverflow.ellipsis,
275+
),
276+
const SizedBox(height: 4),
277+
Row(
278+
children: [
279+
Container(
280+
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
281+
decoration: BoxDecoration(
282+
color: Theme.of(context).colorScheme.surfaceVariant,
283+
borderRadius: BorderRadius.circular(4),
284+
),
285+
child: Text(
286+
contractType,
287+
style: Theme.of(context).textTheme.bodySmall!.copyWith(
288+
color: Theme.of(context).colorScheme.onSurfaceVariant,
289+
fontWeight: FontWeight.w500,
290+
),
291+
),
292+
),
293+
if (name.isNotEmpty) ...[
294+
const SizedBox(width: 8),
295+
Expanded(
296+
child: Text(
297+
name,
298+
style: Theme.of(context).textTheme.bodySmall!.copyWith(
299+
color: Theme.of(context).colorScheme.onSurfaceVariant,
300+
),
301+
overflow: TextOverflow.ellipsis,
302+
),
303+
),
304+
],
305+
],
306+
),
307+
],
308+
),
309+
),
310+
_buildStatusBadge(context, state),
311+
],
312+
),
313+
const SizedBox(height: 12),
314+
const Divider(height: 1),
315+
Padding(
316+
padding: const EdgeInsets.only(top: 12.0),
317+
child: Row(
318+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
319+
children: [
320+
Text(
321+
'View Details',
322+
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
323+
color: Theme.of(context).colorScheme.primary,
324+
fontWeight: FontWeight.w500,
325+
),
326+
),
327+
Icon(
328+
Icons.arrow_forward_ios,
329+
size: 16,
330+
color: Theme.of(context).colorScheme.primary,
331+
),
332+
],
333+
),
334+
),
335+
],
336+
),
337+
),
338+
),
339+
);
340+
}
341+
342+
Widget _buildStatusBadge(BuildContext context, String status) {
343+
final lowerStatus = status.toLowerCase();
344+
Color backgroundColor;
345+
Color textColor;
346+
347+
if (lowerStatus == 'created') {
348+
backgroundColor = Theme.of(context).colorScheme.primaryContainer;
349+
textColor = Theme.of(context).colorScheme.onPrimary;
350+
} else {
351+
backgroundColor = Theme.of(context).colorScheme.warningContainer;
352+
textColor = Theme.of(context).colorScheme.warning;
353+
}
354+
355+
return Container(
356+
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
357+
decoration: BoxDecoration(
358+
color: backgroundColor,
359+
borderRadius: BorderRadius.circular(16),
360+
),
361+
child: Text(
362+
_capitalizeFirstLetter(lowerStatus),
363+
style: Theme.of(context).textTheme.labelSmall!.copyWith(
364+
color: textColor,
365+
fontWeight: FontWeight.bold,
366+
),
367+
),
368+
);
369+
}
370+
371+
String _capitalizeFirstLetter(String text) {
372+
if (text.isEmpty) return text;
373+
return text[0].toUpperCase() + text.substring(1);
374+
}
375+
}
376+
377+
class ContractDetailScreen extends StatelessWidget {
378+
final ContractInfo contract;
379+
380+
const ContractDetailScreen({
381+
super.key,
382+
required this.contract,
383+
});
384+
385+
@override
386+
Widget build(BuildContext context) {
387+
return Scaffold(
388+
appBar: AppBar(
389+
title: Text('Contract ${contract.contract_id}'),
390+
elevation: 0,
391+
),
392+
body: SingleChildScrollView(
393+
child: ContractDetails(
394+
contract: contract,
395+
),
396+
),
397+
);
398+
}
399+
}

0 commit comments

Comments
 (0)