import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/connection_settings.dart'; import 'package:revoltvpn/logic/installed_apps_service.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; class AppRoutingTile extends StatefulWidget { final ConnectionMode connectionMode; const AppRoutingTile({super.key, required this.connectionMode}); @override State createState() => _AppRoutingTileState(); } class _AppRoutingTileState extends State { AppRoutingMode _mode = ConnectionSettings.routingMode; int _selectedCount = ConnectionSettings.appPackages.length; bool get _proxyMode => widget.connectionMode == ConnectionMode.proxy; @override void initState() { super.initState(); _load(); } Future _load() async { await ConnectionSettings.initialize(); if (!mounted) return; setState(() { _mode = ConnectionSettings.routingMode; _selectedCount = ConnectionSettings.appPackages.length; }); } bool _vpnBusy() { final status = context.read().status; return status == VpnStatus.connected || status == VpnStatus.connecting || status == VpnStatus.disconnecting; } void _showDisconnectFirst() { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Disconnect before changing app routing.')), ); } Future _changeMode(AppRoutingMode? next) async { if (next == null || next == _mode) return; if (_vpnBusy()) { _showDisconnectFirst(); return; } await ConnectionSettings.setRoutingMode(next); if (mounted) setState(() => _mode = next); } Future _openPicker() async { if (_mode == AppRoutingMode.all) return; if (_vpnBusy()) { _showDisconnectFirst(); return; } await Navigator.of(context).push( MaterialPageRoute( builder: (_) => _AppPicker(mode: _mode), ), ); if (mounted) { setState(() => _selectedCount = ConnectionSettings.appPackages.length); } } String get _subtitle { if (_proxyMode) { switch (_mode) { case AppRoutingMode.all: return 'Transparent SOCKS5 routes all apps automatically.'; case AppRoutingMode.exclude: return _selectedCount == 0 ? 'Transparent SOCKS5 routes all apps. No apps are excluded.' : 'SOCKS5 routes all apps except $_selectedCount selected bypass app${_selectedCount == 1 ? '' : 's'}.'; case AppRoutingMode.selected: return _selectedCount == 0 ? 'Choose apps for compatibility selected-app SOCKS routing.' : 'SOCKS5 routes $_selectedCount selected app${_selectedCount == 1 ? '' : 's'} while other launcher apps bypass it.'; } } switch (_mode) { case AppRoutingMode.all: return 'All apps use the TUN VPN.'; case AppRoutingMode.exclude: return _selectedCount == 0 ? 'No apps excluded from TUN.' : '$_selectedCount app${_selectedCount == 1 ? '' : 's'} bypass the TUN VPN.'; case AppRoutingMode.selected: return _selectedCount == 0 ? 'Choose at least one app for compatible Selected only routing.' : '$_selectedCount selected user app${_selectedCount == 1 ? '' : 's'} are targeted; Android network helpers stay available.'; } } @override Widget build(BuildContext context) { final showPicker = _mode != AppRoutingMode.all; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ListTile( title: const Text( 'App routing', style: TextStyle(color: AppColors.textWhite, fontSize: 15), ), subtitle: Text( _subtitle, style: const TextStyle(color: AppColors.textDim, fontSize: 12), ), trailing: DropdownButtonHideUnderline( child: DropdownButton( value: _mode, dropdownColor: AppColors.bgCard, onChanged: _changeMode, items: const [ DropdownMenuItem( value: AppRoutingMode.all, child: Text('All apps'), ), DropdownMenuItem( value: AppRoutingMode.exclude, child: Text('Exclude apps'), ), DropdownMenuItem( value: AppRoutingMode.selected, child: Text('Selected only'), ), ], ), ), ), if (showPicker) ListTile( onTap: _openPicker, leading: const Icon(Icons.apps), title: Text( _mode == AppRoutingMode.selected ? 'Choose routed apps' : 'Choose excluded apps', ), subtitle: Text( _mode == AppRoutingMode.selected ? 'Selected user apps are targeted; Android network helpers remain available for compatibility.' : 'Routing is applied to the selected Android package IDs.', ), trailing: const Icon(Icons.chevron_right), ), ], ); } } class _AppPicker extends StatefulWidget { final AppRoutingMode mode; const _AppPicker({required this.mode}); @override State<_AppPicker> createState() => _AppPickerState(); } class _AppPickerState extends State<_AppPicker> { final Set _selected = {...ConnectionSettings.appPackages}; final TextEditingController _search = TextEditingController(); List _apps = const []; bool _loading = true; String? _error; String _query = ''; @override void initState() { super.initState(); _loadApps(); } @override void dispose() { _search.dispose(); super.dispose(); } Future _loadApps() async { try { final apps = await InstalledAppsService.loadLaunchableApps(); if (!mounted) return; setState(() { _apps = apps; _loading = false; }); } catch (_) { if (!mounted) return; setState(() { _error = 'Could not load installed apps.'; _loading = false; }); } } Future _save() async { if (widget.mode == AppRoutingMode.selected && _selected.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Select at least one app.')), ); return; } await ConnectionSettings.setAppPackages(_selected); if (mounted) Navigator.of(context).pop(); } @override Widget build(BuildContext context) { final query = _query.trim().toLowerCase(); final visibleApps = query.isEmpty ? _apps : _apps .where( (app) => app.label.toLowerCase().contains(query) || app.packageName.toLowerCase().contains(query), ) .toList(); final title = widget.mode == AppRoutingMode.exclude ? 'Exclude apps' : 'Selected routed apps'; return Scaffold( backgroundColor: AppColors.bgDeep, appBar: AppBar( backgroundColor: AppColors.bgSurface, title: Text(title), actions: [ TextButton( onPressed: _save, child: const Text('Save'), ), ], ), body: Column( children: [ Padding( padding: const EdgeInsets.all(12), child: TextField( controller: _search, onChanged: (value) => setState(() => _query = value), decoration: const InputDecoration( hintText: 'Search apps', prefixIcon: Icon(Icons.search), ), ), ), Expanded(child: _buildList(visibleApps)), ], ), ); } Widget _buildList(List visibleApps) { if (_loading) { return const Center(child: CircularProgressIndicator()); } if (_error != null) { return Center( child: Text(_error!, style: const TextStyle(color: AppColors.textMuted)), ); } if (visibleApps.isEmpty) { return const Center( child: Text('No apps found.', style: TextStyle(color: AppColors.textMuted)), ); } return ListView.builder( itemCount: visibleApps.length, itemBuilder: (context, index) { final app = visibleApps[index]; final selected = _selected.contains(app.packageName); return CheckboxListTile( value: selected, onChanged: (value) { setState(() { if (value == true) { _selected.add(app.packageName); } else { _selected.remove(app.packageName); } }); }, secondary: app.iconPng == null ? const CircleAvatar(child: Icon(Icons.android, size: 20)) : ClipRRect( borderRadius: BorderRadius.circular(10), child: Image.memory( app.iconPng!, width: 40, height: 40, fit: BoxFit.cover, gaplessPlayback: true, ), ), title: Text(app.label), subtitle: Text( app.packageName, style: const TextStyle(color: AppColors.textDim, fontSize: 11), ), ); }, ); } }