import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app_routing_screen.dart'; import 'logs_screen.dart'; import 'qr_scanner_screen.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../models/ostp_profile.dart'; /// Picks readable black/white text for a given (opaque) background color. /// The monochrome theme's `primary` is pure white — hardcoded white text on /// top of it was invisible; this picks the contrasting color instead. Color _onColor(Color bg) { return ThemeData.estimateBrightnessForColor(bg) == Brightness.light ? Colors.black : Colors.white; } class SettingsScreen extends StatefulWidget { final SharedPreferences prefs; const SettingsScreen({super.key, required this.prefs}); @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { late TextEditingController _localBindCtrl; late TextEditingController _dnsCtrl; late TextEditingController _mtuCtrl; late TextEditingController _domainsCtrl; late TextEditingController _ipsCtrl; late TextEditingController _muxSessionsCtrl; bool _debugMode = false; bool _muxEnabled = false; bool _isCheckingUpdates = false; bool _showSpeed = true; bool _showRtt = true; List _profiles = []; @override void initState() { super.initState(); _loadSettings(); } void _loadSettings() { _localBindCtrl = TextEditingController(text: widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'); _dnsCtrl = TextEditingController(text: widget.prefs.getString('dns_server') ?? '1.1.1.1'); _mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140'); _domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? ''); _ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? ''); // No "Bypass Processes" field on mobile — Android per-app selection // (Configure Split Tunneling) already covers this; a process-name field // doesn't map to anything meaningful on Android the way it does on desktop. _debugMode = widget.prefs.getBool('debug_mode') ?? false; _muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; _showSpeed = widget.prefs.getBool('show_speed') ?? true; _showRtt = widget.prefs.getBool('show_rtt') ?? true; _muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2'); _profiles = decodeProfiles(widget.prefs.getString('profiles_json')); } @override void dispose() { _saveSettings(); _localBindCtrl.dispose(); _dnsCtrl.dispose(); _mtuCtrl.dispose(); _domainsCtrl.dispose(); _ipsCtrl.dispose(); _muxSessionsCtrl.dispose(); super.dispose(); } void _saveSettings() { widget.prefs.setString('local_bind', _localBindCtrl.text.trim()); widget.prefs.setString('dns_server', _dnsCtrl.text.trim()); widget.prefs.setString('mtu', _mtuCtrl.text.trim()); widget.prefs.setString('ex_domains', _domainsCtrl.text.trim()); widget.prefs.setString('ex_ips', _ipsCtrl.text.trim()); widget.prefs.setBool('debug_mode', _debugMode); widget.prefs.setBool('mux_enabled', _muxEnabled); widget.prefs.setBool('show_speed', _showSpeed); widget.prefs.setBool('show_rtt', _showRtt); widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim()); widget.prefs.setString('profiles_json', encodeProfiles(_profiles)); } void _saveProfiles() { widget.prefs.setString('profiles_json', encodeProfiles(_profiles)); } // ── Profile CRUD ───────────────────────────────────────────────────────── void _selectActive(OstpProfile p) { setState(() { for (final other in _profiles) { other.active = other.id == p.id; } _saveProfiles(); }); } void _importFromLink(String link) { if (link.isEmpty) return; try { if (!link.startsWith('ostp://')) { throw Exception('Link must start with ostp://'); } final uri = Uri.parse(link); final key = Uri.decodeComponent(uri.userInfo); final host = uri.authority.replaceFirst('${uri.userInfo}@', ''); if (key.isEmpty || host.isEmpty) { throw Exception('Incomplete link parameters'); } final type = uri.queryParameters['type']; final transportMode = (type == 'tcp' || type == 'http') ? 'uot' : 'udp'; final name = uri.queryParameters['name'] ?? host; final wasEmpty = _profiles.isEmpty; setState(() { _profiles.add(OstpProfile( id: DateTime.now().millisecondsSinceEpoch.toString(), name: name, serverAddr: host, accessKey: key, transportMode: transportMode, active: wasEmpty, )); _saveProfiles(); }); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Imported successfully'))); } catch (e) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); } } void _showAddProfileMenu() { showModalBottomSheet( context: context, backgroundColor: Theme.of(context).colorScheme.surface, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.qr_code_scanner, color: Colors.white), title: const Text('Import from QR code'), onTap: () async { Navigator.pop(context); final result = await Navigator.push( context, MaterialPageRoute(builder: (context) => const QRScannerScreen()), ); if (result != null && result is String && result.startsWith('ostp://')) { _importFromLink(result); } }, ), ListTile( leading: const Icon(Icons.link, color: Colors.white), title: const Text('Import from link'), onTap: () { Navigator.pop(context); _showImportLinkDialog(); }, ), ListTile( leading: const Icon(Icons.edit, color: Colors.white), title: const Text('Insert manually'), onTap: () { Navigator.pop(context); _showEditProfileDialog(null); }, ), ], ), ), ); } void _showImportLinkDialog() { final linkCtrl = TextEditingController(); showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Import Link'), backgroundColor: Theme.of(context).colorScheme.surface, content: TextField( controller: linkCtrl, decoration: const InputDecoration(hintText: 'ostp://...'), autofocus: true, ), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), TextButton( onPressed: () { Navigator.pop(context); _importFromLink(linkCtrl.text.trim()); }, child: const Text('Import'), ), ], ), ); } void _showEditProfileDialog(OstpProfile? profile) { final isNew = profile == null; final nameCtrl = TextEditingController(text: profile?.name ?? ''); final serverCtrl = TextEditingController(text: profile?.serverAddr ?? ''); final keyCtrl = TextEditingController(text: profile?.accessKey ?? ''); final fragChunkCtrl = TextEditingController(text: (profile?.fragChunk ?? 2).toString()); final fragSleepCtrl = TextEditingController(text: (profile?.fragSleep ?? 2).toString()); final junkPcMinCtrl = TextEditingController(text: (profile?.junkPcMin ?? 2).toString()); final junkPcMaxCtrl = TextEditingController(text: (profile?.junkPcMax ?? 5).toString()); final junkPsMinCtrl = TextEditingController(text: (profile?.junkPsMin ?? 100).toString()); final junkPsMaxCtrl = TextEditingController(text: (profile?.junkPsMax ?? 1000).toString()); String transportMode = profile?.transportMode ?? 'udp'; bool tcpFragmentation = profile?.tcpFragmentation ?? false; bool obscureKey = true; showDialog( context: context, builder: (context) { return StatefulBuilder( builder: (context, setDialogState) => AlertDialog( title: Text(isNew ? 'New Profile' : 'Edit Profile'), backgroundColor: Theme.of(context).colorScheme.surface, content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField(controller: nameCtrl, decoration: const InputDecoration(labelText: 'Name')), const SizedBox(height: 12), TextField(controller: serverCtrl, decoration: const InputDecoration(labelText: 'Server Address (host:port)')), const SizedBox(height: 12), TextField( controller: keyCtrl, obscureText: obscureKey, decoration: InputDecoration( labelText: 'Access Key', suffixIcon: IconButton( icon: Icon(obscureKey ? Icons.visibility : Icons.visibility_off, size: 18), onPressed: () => setDialogState(() => obscureKey = !obscureKey), ), ), ), const SizedBox(height: 16), DropdownButtonFormField( value: transportMode, decoration: const InputDecoration(labelText: 'Transport'), items: const [ DropdownMenuItem(value: 'udp', child: Text('UDP')), DropdownMenuItem(value: 'uot', child: Text('TCP (UoT)')), ], onChanged: (v) { if (v != null) setDialogState(() => transportMode = v); }, ), const Divider(height: 32), // Junk packets + TCP fragmentation moved into their own // modals (tap to configure) — this dialog was carrying too // many fields at once; these two are advanced/occasional // settings, not something every profile edit needs to see. const Text('DPI OBFUSCATION', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white54, letterSpacing: 1.0)), const SizedBox(height: 12), Row( children: [ Expanded( child: OutlinedButton.icon( icon: const Icon(Icons.shuffle_rounded, size: 18), label: const Text('Junk Packets'), onPressed: () => _showJunkPacketsModal( context, junkPcMinCtrl, junkPcMaxCtrl, junkPsMinCtrl, junkPsMaxCtrl, ), ), ), const SizedBox(width: 12), Expanded( child: OutlinedButton.icon( icon: Icon(tcpFragmentation ? Icons.call_split_rounded : Icons.horizontal_rule_rounded, size: 18), label: Text(tcpFragmentation ? 'TCP Frag: On' : 'TCP Frag: Off'), onPressed: () => _showTcpFragModal( context, tcpFragmentation, (v) => setDialogState(() => tcpFragmentation = v), fragChunkCtrl, fragSleepCtrl, ), ), ), ], ), ], ), ), actions: [ if (!isNew) TextButton( onPressed: () { setState(() { final wasActive = profile.active; _profiles.removeWhere((p) => p.id == profile.id); if (wasActive && _profiles.isNotEmpty) { _profiles.first.active = true; } _saveProfiles(); }); Navigator.pop(context); }, child: const Text('Delete', style: TextStyle(color: Colors.redAccent)), ), TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), TextButton( onPressed: () { final server = serverCtrl.text.trim(); final key = keyCtrl.text.trim(); if (server.isEmpty || key.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Server and Access Key are required')), ); return; } setState(() { if (isNew) { final wasEmpty = _profiles.isEmpty; _profiles.add(OstpProfile( id: DateTime.now().millisecondsSinceEpoch.toString(), name: nameCtrl.text.trim().isNotEmpty ? nameCtrl.text.trim() : server, serverAddr: server, accessKey: key, transportMode: transportMode, active: wasEmpty, tcpFragmentation: tcpFragmentation, fragChunk: int.tryParse(fragChunkCtrl.text) ?? 2, fragSleep: int.tryParse(fragSleepCtrl.text) ?? 2, junkPcMin: int.tryParse(junkPcMinCtrl.text) ?? 2, junkPcMax: int.tryParse(junkPcMaxCtrl.text) ?? 5, junkPsMin: int.tryParse(junkPsMinCtrl.text) ?? 100, junkPsMax: int.tryParse(junkPsMaxCtrl.text) ?? 1000, )); } else { profile.name = nameCtrl.text.trim().isNotEmpty ? nameCtrl.text.trim() : server; profile.serverAddr = server; profile.accessKey = key; profile.transportMode = transportMode; profile.tcpFragmentation = tcpFragmentation; profile.fragChunk = int.tryParse(fragChunkCtrl.text) ?? 2; profile.fragSleep = int.tryParse(fragSleepCtrl.text) ?? 2; profile.junkPcMin = int.tryParse(junkPcMinCtrl.text) ?? 2; profile.junkPcMax = int.tryParse(junkPcMaxCtrl.text) ?? 5; profile.junkPsMin = int.tryParse(junkPsMinCtrl.text) ?? 100; profile.junkPsMax = int.tryParse(junkPsMaxCtrl.text) ?? 1000; } _saveProfiles(); }); Navigator.pop(context); }, child: const Text('Save'), ), ], ), ); }, ); } // The numeric fields below all edit the SAME TextEditingControllers that // the outer profile-edit dialog already holds — no extra propagation is // needed for them, closing this modal just leaves the shared controllers // updated. Only the `tcpFragmentation` bool (not a controller) needs an // explicit callback to reach back into the outer dialog's state. void _showJunkPacketsModal( BuildContext context, TextEditingController pcMin, TextEditingController pcMax, TextEditingController psMin, TextEditingController psMax, ) { showDialog( context: context, builder: (context) => AlertDialog( backgroundColor: Theme.of(context).colorScheme.surface, title: const Text('Junk Packets'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Sends random-size filler packets before the handshake so DPI can\'t fingerprint its size or timing.', style: TextStyle(fontSize: 12, color: Colors.white54), ), const SizedBox(height: 16), Row( children: [ Expanded(child: TextField(controller: pcMin, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Count (min)'))), const SizedBox(width: 12), Expanded(child: TextField(controller: pcMax, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Count (max)'))), ], ), const SizedBox(height: 12), Row( children: [ Expanded(child: TextField(controller: psMin, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Size min (bytes)'))), const SizedBox(width: 12), Expanded(child: TextField(controller: psMax, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Size max (bytes)'))), ], ), ], ), ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Done'))], ), ); } void _showTcpFragModal( BuildContext context, bool initialEnabled, ValueChanged onChanged, TextEditingController chunkCtrl, TextEditingController sleepCtrl, ) { bool enabled = initialEnabled; showDialog( context: context, builder: (context) => StatefulBuilder( builder: (context, setModalState) => AlertDialog( backgroundColor: Theme.of(context).colorScheme.surface, title: const Text('TCP Fragmentation'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Enabled', style: TextStyle(fontSize: 14)), subtitle: const Text('Split the handshake into small chunks', style: TextStyle(fontSize: 12, color: Colors.white54)), value: enabled, onChanged: (v) => setModalState(() => enabled = v), ), if (enabled) ...[ const SizedBox(height: 8), Row( children: [ Expanded(child: TextField(controller: chunkCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Chunk size (bytes)'))), const SizedBox(width: 12), Expanded(child: TextField(controller: sleepCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Delay (ms)'))), ], ), ], ], ), ), actions: [ TextButton( onPressed: () { onChanged(enabled); Navigator.pop(context); }, child: const Text('Done'), ), ], ), ), ); } void _showShareModal(OstpProfile p) { final key = Uri.encodeComponent(p.accessKey); if (p.serverAddr.isEmpty || p.accessKey.isEmpty) return; final queryParams = []; if (p.transportMode != 'udp') queryParams.add('type=${p.transportMode}'); final queryString = queryParams.isEmpty ? '' : '?${queryParams.join('&')}'; final url = 'ostp://$key@${p.serverAddr}$queryString'; showDialog( context: context, builder: (context) => AlertDialog( backgroundColor: Theme.of(context).colorScheme.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), // Deliberately generic — not "Share {name}": when a profile has no // custom name, `name` falls back to the raw server address, and this // dialog is exactly the wrong place to be casually displaying that // (screenshots, screen recordings, shoulder-surfing). title: const Text('Share Profile', textAlign: TextAlign.center), content: Column( mainAxisSize: MainAxisSize.min, children: [ Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), child: QrImageView(data: url, version: QrVersions.auto, size: 200.0), ), const SizedBox(height: 20), Builder(builder: (context) { final bg = Theme.of(context).colorScheme.primary; final fg = _onColor(bg); return ElevatedButton.icon( onPressed: () { Clipboard.setData(ClipboardData(text: url)); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Copied to clipboard'))); Navigator.pop(context); }, icon: Icon(Icons.copy_rounded, color: fg), label: Text('Copy Link', style: TextStyle(color: fg)), style: ElevatedButton.styleFrom( backgroundColor: bg, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ); }), ], ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close'))], ), ); } // ── Widgets ────────────────────────────────────────────────────────────── Widget _buildTextField(String label, TextEditingController controller, {String? hint, int maxLines = 1}) { return Padding( padding: const EdgeInsets.only(bottom: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), const SizedBox(height: 10), TextField( controller: controller, maxLines: maxLines, style: const TextStyle(fontSize: 16), decoration: InputDecoration( hintText: hint, hintStyle: const TextStyle(color: Colors.white30), filled: true, fillColor: Theme.of(context).colorScheme.surface, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), ), ), ], ), ); } Widget _buildToggle(String title, String subtitle, bool value, ValueChanged onChanged) { return Padding( padding: const EdgeInsets.only(bottom: 24), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 4), Text(subtitle, style: const TextStyle(fontSize: 13, color: Colors.white54)), ], ), ), Switch( value: value, onChanged: (v) { setState(() => onChanged(v)); _saveSettings(); }, activeColor: Theme.of(context).colorScheme.secondary, ) ], ), ); } List _buildProfileCards() { String? activeId; for (final x in _profiles) { if (x.active) { activeId = x.id; break; } } return _profiles.map((p) => Card( color: p.active ? Theme.of(context).colorScheme.primary.withOpacity(0.12) : Theme.of(context).colorScheme.surface, margin: const EdgeInsets.only(bottom: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: p.active ? BorderSide(color: Theme.of(context).colorScheme.primary.withOpacity(0.4)) : BorderSide.none, ), child: ListTile( leading: Radio( value: p.id, groupValue: activeId, onChanged: (_) => _selectActive(p), ), title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis), // If the profile was never given a distinct name, `name` falls back // to the raw server address (see the editor below) — showing it a // second time here would just repeat the title verbatim, so only // add it when it's actually different information. subtitle: Text( p.name == p.serverAddr ? p.transportMode.toUpperCase() : '${p.serverAddr} · ${p.transportMode.toUpperCase()}', style: const TextStyle(fontSize: 12), maxLines: 1, overflow: TextOverflow.ellipsis, softWrap: false, ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.share_rounded, size: 20, color: Colors.white54), onPressed: () => _showShareModal(p), ), IconButton( icon: const Icon(Icons.edit, size: 20, color: Colors.white54), onPressed: () => _showEditProfileDialog(p), ), ], ), onTap: () => _selectActive(p), ), )).toList(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Configuration', style: TextStyle(fontWeight: FontWeight.bold)), backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back_rounded), onPressed: () => Navigator.pop(context), ), actions: [ IconButton( icon: const Icon(Icons.add_rounded), tooltip: 'Add Profile', onPressed: _showAddProfileMenu, ), ], ), body: Stack( children: [ Positioned.fill( child: Opacity( opacity: 0.1, child: Center( child: Image.asset( 'assets/logo.png', width: MediaQuery.of(context).size.shortestSide * 0.6, // No color tint needed — the asset now carries real alpha // (background pixels' luminance was baked into alpha, see // git history), so it's already a pure white silhouette. ), ), ), ), ListView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), children: [ const Text('PROFILES', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), const SizedBox(height: 16), if (_profiles.isEmpty) Center( child: Padding( padding: const EdgeInsets.all(32.0), child: Text('Create a new profile', style: TextStyle(color: Colors.white54, fontSize: 18)), ), ) else ..._buildProfileCards(), const SizedBox(height: 32), const Text('CLIENT SETTINGS', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), const SizedBox(height: 16), Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: Colors.white.withOpacity(0.02), borderRadius: BorderRadius.circular(24), border: Border.all(color: Colors.white.withOpacity(0.05)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildToggle('MUX (Multiplexing)', 'Multiple sessions over single connection', _muxEnabled, (v) => _muxEnabled = v), if (_muxEnabled) _buildTextField('MUX Sessions', _muxSessionsCtrl, hint: 'e.g. 2, 4, 8'), _buildToggle('Debug Mode', 'Verbose logging', _debugMode, (v) => _debugMode = v), _buildToggle('Show Speed', 'Live download/upload speed on the home screen', _showSpeed, (v) => _showSpeed = v), _buildToggle('Show RTT', 'Live server ping on the home screen', _showRtt, (v) => _showRtt = v), _buildTextField('Local Proxy Bind', _localBindCtrl, hint: '127.0.0.1:1088'), _buildTextField('Custom DNS Server', _dnsCtrl, hint: '1.1.1.1 (e.g. 8.8.8.8)'), _buildTextField('MTU (Packet Size)', _mtuCtrl, hint: '1140 (decrease if connection drops)'), const Padding( padding: EdgeInsets.only(bottom: 16), child: Row( children: [ Text('Exclusions', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), SizedBox(width: 10), Text('one per line', style: TextStyle(fontSize: 13, color: Colors.white30)), ], ), ), _buildTextField('Bypass Domains', _domainsCtrl, hint: 'example.com\n*.google.com', maxLines: 3), _buildTextField('Bypass IPs / CIDR', _ipsCtrl, hint: '192.168.1.0/24\n10.0.0.1', maxLines: 3), const SizedBox(height: 8), SizedBox( width: double.infinity, child: ElevatedButton.icon( icon: const Icon(Icons.route), label: const Text('Configure Split Tunneling'), onPressed: () { Navigator.push(context, MaterialPageRoute(builder: (context) => AppRoutingScreen(prefs: widget.prefs))); }, ), ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: ElevatedButton.icon( icon: const Icon(Icons.article), label: const Text('View Logs'), onPressed: () { Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen())); }, ), ), ], ), ), const SizedBox(height: 16), InkWell( onTap: _isCheckingUpdates ? null : _checkForUpdates, child: Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), decoration: BoxDecoration( color: Colors.white.withOpacity(0.02), borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.white.withOpacity(0.05)), ), child: Row( children: [ const Icon(Icons.system_update_rounded, color: Colors.white70, size: 24), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Check for Updates', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white)), const SizedBox(height: 4), Text( _isCheckingUpdates ? 'Checking...' : 'Check latest release on GitHub', style: const TextStyle(fontSize: 13, color: Colors.white54), ), ], ), ), if (_isCheckingUpdates) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white54)) else const Icon(Icons.arrow_forward_ios_rounded, color: Colors.white54, size: 16), ], ), ), ), const SizedBox(height: 40), ], ), ], ), ); } Future _checkForUpdates() async { if (_isCheckingUpdates) return; setState(() { _isCheckingUpdates = true; }); try { final packageInfo = await PackageInfo.fromPlatform(); final currentVersion = packageInfo.version; final response = await http.get(Uri.parse('https://api.github.com/repos/ospab/ostp/releases/latest')); if (response.statusCode == 200) { final data = json.decode(response.body); final latestVersion = (data['tag_name'] as String).replaceAll('v', ''); final hasUpdate = latestVersion != currentVersion; if (!mounted) return; showDialog( context: context, builder: (context) { return AlertDialog( backgroundColor: Theme.of(context).colorScheme.surface, title: Text(hasUpdate ? 'Update Available!' : 'Up to Date'), content: Text(hasUpdate ? 'A new version ($latestVersion) is available on GitHub. You are currently running version $currentVersion.' : 'You are running the latest version ($currentVersion).'), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')), if (hasUpdate) TextButton( onPressed: () { Navigator.pop(context); final url = Uri.parse(data['html_url'] ?? 'https://github.com/ospab/ostp/releases/latest'); launchUrl(url, mode: LaunchMode.externalApplication); }, child: const Text('Download'), ) ], ); }, ); } else { throw Exception('HTTP ${response.statusCode}'); } } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error checking updates: $e'))); } finally { if (mounted) setState(() { _isCheckingUpdates = false; }); } } }