import 'dart:async';
import 'dart:convert';
import 'package:dynamic_sdk/dynamic_sdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Global navigator key used by WcGlobalListener to show dialogs
/// from any context, even above the Navigator in the widget tree.
final wcNavigatorKey = GlobalKey<NavigatorState>();
class WcGlobalListener extends StatefulWidget {
final Widget child;
const WcGlobalListener({super.key, required this.child});
@override
State<WcGlobalListener> createState() => _WcGlobalListenerState();
}
class _WcGlobalListenerState extends State<WcGlobalListener> {
final _wc = DynamicSDK.instance.walletConnect;
StreamSubscription<WcSessionProposal?>? _proposalSub;
StreamSubscription<WcSessionRequest?>? _requestSub;
StreamSubscription<String?>? _deleteSub;
StreamSubscription<String?>? _authSub;
bool _initialized = false;
bool _initializing = false;
@override
void initState() {
super.initState();
_listenForAuth();
}
@override
void dispose() {
_proposalSub?.cancel();
_requestSub?.cancel();
_deleteSub?.cancel();
_authSub?.cancel();
super.dispose();
}
void _listenForAuth() {
_authSub = DynamicSDK.instance.auth.tokenChanges.listen((token) {
if (token != null &&
token.isNotEmpty &&
!_initialized &&
!_initializing) {
_initialize();
} else if (token == null || token.isEmpty) {
_initialized = false;
_initializing = false;
}
});
// Check if already logged in
final token = DynamicSDK.instance.auth.token;
if (token != null && token.isNotEmpty) {
_initialize();
}
}
Future<void> _initialize() async {
if (_initializing || _initialized) return;
_initializing = true;
try {
await _wc.initialize();
_initialized = true;
_listenForEvents();
} catch (e) {
debugPrint('[WcGlobalListener] Failed to initialize: $e');
}
_initializing = false;
}
BuildContext? get _navigatorContext =>
wcNavigatorKey.currentState?.overlay?.context;
void _listenForEvents() {
_proposalSub?.cancel();
_requestSub?.cancel();
_deleteSub?.cancel();
// Session proposal — a dApp wants to connect
_proposalSub = _wc.onSessionProposal.listen((proposal) {
if (proposal != null && mounted && _navigatorContext != null) {
_showProposalDialog(proposal);
}
});
// Session request — a dApp wants the user to sign something
_requestSub = _wc.onSessionRequest.listen((request) {
if (request != null && mounted && _navigatorContext != null) {
_showRequestDialog(request);
}
});
// Session deleted
_deleteSub = _wc.onSessionDelete.listen((topic) {
final navCtx = _navigatorContext;
if (topic != null && mounted && navCtx != null) {
ScaffoldMessenger.of(navCtx).showSnackBar(
SnackBar(
content: Text(
'Session disconnected: ${topic.substring(0, 8)}...',
),
),
);
}
});
}
void _showProposalDialog(WcSessionProposal proposal) {
final navCtx = _navigatorContext;
if (navCtx == null) return;
showDialog(
context: navCtx,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Session Proposal'),
content: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(ctx).size.height * 0.6,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
proposal.proposer.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
if (proposal.proposer.description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
proposal.proposer.description,
style: TextStyle(color: Colors.grey[600]),
),
),
if (proposal.proposer.url.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
proposal.proposer.url,
style: const TextStyle(
color: Colors.blue,
fontSize: 12,
),
),
),
const SizedBox(height: 16),
if (proposal.requiredNamespaces != null) ...[
const Text(
'Required chains:',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
...proposal.requiredNamespaces!.entries.map(
(e) => Text(
' ${e.key}: ${e.value.chains.join(", ")}',
),
),
],
if (proposal.optionalNamespaces != null &&
proposal.optionalNamespaces!.isNotEmpty) ...[
const SizedBox(height: 8),
const Text(
'Optional chains:',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
...proposal.optionalNamespaces!.entries.map(
(e) => Text(
' ${e.key}: ${e.value.chains.join(", ")}',
),
),
],
],
),
),
),
actions: [
TextButton(
onPressed: () async {
Navigator.of(ctx).pop();
await _wc.confirmPairing(confirm: false);
},
child: const Text('Reject'),
),
FilledButton(
onPressed: () async {
Navigator.of(ctx).pop();
try {
await _wc.confirmPairing(confirm: true);
final snackCtx = _navigatorContext;
if (mounted && snackCtx != null) {
ScaffoldMessenger.of(snackCtx).showSnackBar(
const SnackBar(
content: Text('Session approved'),
),
);
}
} catch (e) {
final snackCtx = _navigatorContext;
if (mounted && snackCtx != null) {
ScaffoldMessenger.of(snackCtx).showSnackBar(
SnackBar(
content: Text('Approval failed: $e'),
),
);
}
}
},
child: const Text('Approve'),
),
],
),
);
}
void _showRequestDialog(WcSessionRequest request) {
final navCtx = _navigatorContext;
if (navCtx == null) return;
showDialog(
context: navCtx,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Session Request'),
content: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(ctx).size.height * 0.6,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_infoRow('Method', request.method),
_infoRow('Chain', request.chainId),
_infoRow(
'Topic',
'${request.topic.substring(0, 12)}...',
),
if (request.params != null) ...[
const SizedBox(height: 8),
Row(
children: [
const Text(
'Params:',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const Spacer(),
InkWell(
onTap: () {
final json =
const JsonEncoder.withIndent(' ')
.convert(request.params);
Clipboard.setData(
ClipboardData(text: json),
);
ScaffoldMessenger.of(ctx).showSnackBar(
const SnackBar(
content: Text('Params copied'),
duration: Duration(seconds: 1),
),
);
},
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(Icons.copy, size: 16),
),
),
],
),
Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
constraints:
const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: Text(
request.params.toString(),
style: const TextStyle(
fontSize: 11,
fontFamily: 'monospace',
),
),
),
),
],
],
),
),
),
actions: [
TextButton(
onPressed: () async {
Navigator.of(ctx).pop();
await _wc.respondSessionRequest(
id: request.id,
topic: request.topic,
approved: false,
);
},
child: const Text('Reject'),
),
FilledButton(
onPressed: () async {
Navigator.of(ctx).pop();
await _wc.respondSessionRequest(
id: request.id,
topic: request.topic,
approved: true,
);
},
child: const Text('Approve'),
),
],
),
);
}
Widget _infoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 60,
child: Text(
'$label:',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontSize: 13),
),
),
],
),
);
}
@override
Widget build(BuildContext context) => widget.child;
}