@@ -8,6 +8,8 @@ import 'package:path_provider/path_provider.dart';
88import 'package:logging/logging.dart' ;
99import 'package:flutter_desktop_sleep/flutter_desktop_sleep.dart' ;
1010import 'package:flutter_window_close/flutter_window_close.dart' ;
11+ import 'package:http/http.dart' as http;
12+ import 'dart:convert' ;
1113
1214import 'myceliumflut_ffi_binding.dart' ;
1315
@@ -50,13 +52,21 @@ class _MyAppState extends State<MyApp> {
5052 String _nodeAddr = '' ;
5153 var privKey = Uint8List (0 );
5254 List <String > peers = [];
55+ Map <String , String > _peerToCountry = {};
56+ Timer ? _geoDebounce;
57+ late FocusNode _peersFocusNode;
58+ String _rawPeersText = '' ;
5359 late TextEditingController textEditController;
5460 final _flutterDesktopSleepPlugin = FlutterDesktopSleep ();
55- final ScrollController _scrollController = ScrollController ();
61+
5662
5763 @override
5864 void initState () {
5965 textEditController = TextEditingController (text: '' );
66+ _peersFocusNode = FocusNode ();
67+ _peersFocusNode.addListener (() {
68+ setState (() {});
69+ });
6070 super .initState ();
6171 initPlatformState ();
6272 platform.setMethodCallHandler ((MethodCall call) async {
@@ -152,7 +162,9 @@ class _MyAppState extends State<MyApp> {
152162 'tcp://5.223.43.251:9651'
153163 ];
154164 }
155- textEditController = TextEditingController (text: peers.join ('\n ' ));
165+ _rawPeersText = peers.join ('\n ' );
166+ textEditController = TextEditingController (text: _rawPeersText);
167+ await _fetchAndGroupPeersByCountry (peers);
156168
157169 String nodeAddr;
158170 if (isUseDylib ()) {
@@ -173,6 +185,95 @@ class _MyAppState extends State<MyApp> {
173185 _nodeAddr = nodeAddr;
174186 });
175187 }
188+ Future <void > _fetchAndGroupPeersByCountry (List <String > peersList) async {
189+ try {
190+ final Map <String , String > p2c = {};
191+ for (final p in peersList) {
192+ final host = _extractHost (p);
193+ if (host.isEmpty) continue ;
194+ final ip = await _resolveHost (host);
195+ if (ip == null ) continue ;
196+ final info = await _geoLookup (ip);
197+ final country = info['country_name' ]? .toString () ?? 'Unknown' ;
198+ p2c[p] = country;
199+ }
200+ if (mounted) {
201+ setState (() {
202+ _peerToCountry = p2c;
203+ });
204+ }
205+ } catch (e) {
206+ _logger.warning ('geo overlay update failed' );
207+ }
208+ }
209+
210+ String _extractHost (String peer) {
211+ try {
212+ final withoutScheme = peer.substring (peer.indexOf ('://' ) + 3 );
213+ if (withoutScheme.startsWith ('[' )) {
214+ final end = withoutScheme.indexOf (']' );
215+ if (end > 1 ) return withoutScheme.substring (1 , end);
216+ return '' ;
217+ }
218+ final colon = withoutScheme.lastIndexOf (':' );
219+ if (colon > 0 ) return withoutScheme.substring (0 , colon);
220+ return '' ;
221+ } catch (_) {
222+ return '' ;
223+ }
224+ }
225+
226+ Future <String ?> _resolveHost (String host) async {
227+ try {
228+ final addresses = await InternetAddress .lookup (host);
229+ if (addresses.isNotEmpty) {
230+ return addresses.first.address;
231+ }
232+ } catch (_) {}
233+ return null ;
234+ }
235+
236+ Future <Map <String , dynamic >> _geoLookup (String ip) async {
237+ final uri = Uri .parse ('https://geoip.grid.tf/?ip=${Uri .encodeQueryComponent (ip )}' );
238+ final resp = await http.get (uri, headers: {'Accept' : 'application/json' });
239+ if (resp.statusCode != 200 ) {
240+ throw Exception ('geo lookup failed: ${resp .statusCode }' );
241+ }
242+ return json.decode (resp.body) as Map <String , dynamic >;
243+ }
244+
245+ Widget _buildGreyCountryOverlay (String rawText, Map <String , String > map) {
246+ final lines = rawText.split ('\n ' );
247+ final spans = < TextSpan > [];
248+ for (var i = 0 ; i < lines.length; i++ ) {
249+ final line = lines[i];
250+ final trimmedLine = line.trim ();
251+ if (trimmedLine.isEmpty) {
252+ spans.add (const TextSpan (text: '\n ' ));
253+ continue ;
254+ }
255+ final country = map[trimmedLine];
256+ if (country != null ) {
257+ spans.add (TextSpan (
258+ text: '$line — ' ,
259+ style: const TextStyle (fontSize: 14 , color: Colors .black),
260+ ));
261+ spans.add (TextSpan (
262+ text: country + (i < lines.length - 1 ? '\n ' : '' ),
263+ style: const TextStyle (fontSize: 14 , color: Colors .grey),
264+ ));
265+ } else {
266+ spans.add (TextSpan (
267+ text: line + (i < lines.length - 1 ? '\n ' : '' ),
268+ style: const TextStyle (fontSize: 14 , color: Colors .black),
269+ ));
270+ }
271+ }
272+ return RichText (
273+ text: TextSpan (children: spans),
274+ textAlign: TextAlign .left,
275+ );
276+ }
176277
177278 // start/stop mycelium button variables
178279 bool _isStarted = false ;
@@ -187,6 +288,7 @@ class _MyAppState extends State<MyApp> {
187288 void dispose () {
188289 // Clean up the controller when the widget is disposed.
189290 textEditController.dispose ();
291+ _peersFocusNode.dispose ();
190292 super .dispose ();
191293 }
192294
@@ -265,22 +367,52 @@ class _MyAppState extends State<MyApp> {
265367 constraints: BoxConstraints (
266368 maxHeight: 150 ,
267369 ),
268- child: SingleChildScrollView (
269- controller: _scrollController,
270- child: TextField (
271- // peers address
272- controller: textEditController,
273- onTapOutside: (event) => {
274- FocusManager .instance.primaryFocus? .unfocus (),
275- },
276- minLines: 1 ,
277- maxLines: null ,
278- keyboardType: TextInputType .multiline,
279- style: const TextStyle (fontSize: 14 ),
280- decoration: const InputDecoration (
281- border: OutlineInputBorder (),
282- //labelText: 'Peers',
283- ),
370+ child: Container (
371+ decoration: BoxDecoration (
372+ border: Border .all (color: Colors .grey),
373+ borderRadius: BorderRadius .circular (4.0 ),
374+ ),
375+ child: Stack (
376+ children: [
377+ TextField (
378+ controller: textEditController,
379+ focusNode: _peersFocusNode,
380+ onChanged: (v) {
381+ _rawPeersText = v;
382+ _geoDebounce? .cancel ();
383+ _geoDebounce = Timer (const Duration (milliseconds: 500 ), () async {
384+ final currentPeers = preprocessPeers (getPeers (_rawPeersText));
385+ await _fetchAndGroupPeersByCountry (currentPeers);
386+ if (mounted) setState (() {});
387+ });
388+ },
389+ onTapOutside: (event) => {
390+ FocusManager .instance.primaryFocus? .unfocus (),
391+ },
392+ minLines: 1 ,
393+ maxLines: null ,
394+ keyboardType: TextInputType .multiline,
395+ style: TextStyle (
396+ fontSize: 14 ,
397+ color: (! _peersFocusNode.hasFocus && _peerToCountry.isNotEmpty)
398+ ? Colors .transparent
399+ : Colors .black,
400+ ),
401+ decoration: const InputDecoration (
402+ border: InputBorder .none,
403+ contentPadding: EdgeInsets .all (12.0 ),
404+ ),
405+ ),
406+ if (! _peersFocusNode.hasFocus && _peerToCountry.isNotEmpty)
407+ Positioned .fill (
408+ child: IgnorePointer (
409+ child: Container (
410+ padding: const EdgeInsets .all (12.0 ),
411+ child: _buildGreyCountryOverlay (_rawPeersText, _peerToCountry),
412+ ),
413+ ),
414+ ),
415+ ],
284416 ),
285417 ),
286418 ),
@@ -445,6 +577,10 @@ class _MyAppState extends State<MyApp> {
445577 sendPort.send ('done' );
446578 }
447579
580+
581+
582+
583+
448584 void stopMycelium () {
449585 try {
450586 stopVpn (platform);
0 commit comments