mirror of https://github.com/ospab/ostp.git
fix(flutter): declutter profile editor, fix contrast bugs, unblock app list
- Profile edit dialog: moved junk packets + TCP fragmentation into their own
modals (tap-to-configure), replacing 5 inline field rows with a compact
2-button row. These are occasional/advanced settings, not something every
profile edit needs to see up front.
- Profile card: subtitle repeated the server address verbatim whenever a
profile had no custom name (name falls back to serverAddr) — showing
"1.2.3.4:50000" as both title AND subtitle, with transport mode tacked on
the end of the second copy. Now only shown once; added maxLines/ellipsis
so long addresses truncate instead of wrapping awkwardly.
- Mobile: removed the "Bypass Processes" field entirely (editor UI, prefs
key, config JSON). Android per-app selection (Configure Split Tunneling)
is the real, correct control here — a process-name text field doesn't map
to anything meaningful on Android the way it does on desktop.
- Share icon changed from a QR icon (redundant — the modal already shows a
QR code) to the standard Material share glyph. Share modal title no
longer interpolates the profile's name, which — same root cause as
above — can silently BE the raw server address; title is now generic
("Share Profile") so a screenshot/recording can't leak host:port through it.
- Contrast: the monochrome theme's colorScheme.primary is pure white
(0xFFFFFFFF); several buttons hardcoded white text/icons on top of it
(Bypass/Proxy mode toggles, Copy Link), making them invisible when active.
Added an _onColor() helper (luminance-based black/white pick) and applied
it everywhere a button's foreground sits on a theme color.
- "Configure Split Tunneling" appeared to hang for 10-15s before doing
anything: MainActivity.kt's getInstalledApps handler enumerated every
installed package AND decoded+re-encoded each one's icon synchronously
inside the MethodChannel callback, which runs on the main/UI thread by
default — blocking it for the whole duration meant Flutter couldn't
render ANY frame, not even the loading spinner, until it finished. Moved
the work onto a background Thread; only the final result.success() hops
back via runOnUiThread(). Navigation + spinner now show immediately.
This commit is contained in:
parent
7d9e5faeec
commit
1b3390a3cf
|
|
@ -96,8 +96,18 @@ class MainActivity : FlutterActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"getInstalledApps" -> {
|
"getInstalledApps" -> {
|
||||||
try {
|
// MethodChannel handlers run on the main/UI thread by default.
|
||||||
|
// Enumerating every installed package AND decoding+re-encoding
|
||||||
|
// each one's icon to PNG/base64 is expensive (100+ apps is
|
||||||
|
// common) — done inline here it blocked the main thread for
|
||||||
|
// 10-15s, during which Flutter couldn't render ANY frame, not
|
||||||
|
// even the "loading" spinner, so the screen just appeared to
|
||||||
|
// hang before jumping straight to the fully-loaded list.
|
||||||
|
// Do the work on a background thread; only the final
|
||||||
|
// `result.success(...)` needs to hop back onto the UI thread.
|
||||||
val pm = packageManager
|
val pm = packageManager
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||||
val list = apps.map { app ->
|
val list = apps.map { app ->
|
||||||
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
|
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
|
||||||
|
|
@ -110,10 +120,11 @@ class MainActivity : FlutterActivity() {
|
||||||
"icon" to (iconBase64 ?: "")
|
"icon" to (iconBase64 ?: "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
result.success(list)
|
runOnUiThread { result.success(list) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
result.error("ERROR", e.message, null)
|
runOnUiThread { result.error("ERROR", e.message, null) }
|
||||||
}
|
}
|
||||||
|
}.start()
|
||||||
}
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,13 @@ class AppRoutingScreen extends StatefulWidget {
|
||||||
State<AppRoutingScreen> createState() => _AppRoutingScreenState();
|
State<AppRoutingScreen> createState() => _AppRoutingScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 _AppRoutingScreenState extends State<AppRoutingScreen> {
|
class _AppRoutingScreenState extends State<AppRoutingScreen> {
|
||||||
static const platform = MethodChannel('com.ospab.ostp/vpn');
|
static const platform = MethodChannel('com.ospab.ostp/vpn');
|
||||||
|
|
||||||
|
|
@ -154,10 +161,13 @@ class _AppRoutingScreenState extends State<AppRoutingScreen> {
|
||||||
color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.1),
|
color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Bypass Mode',
|
'Bypass Mode',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _routingMode == 'bypass' ? _onColor(theme.colorScheme.primary) : Colors.white70,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -181,10 +191,13 @@ class _AppRoutingScreenState extends State<AppRoutingScreen> {
|
||||||
color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.1),
|
color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Proxy Mode',
|
'Proxy Mode',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _routingMode == 'proxy' ? _onColor(theme.colorScheme.secondary) : Colors.white70,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
final p = _activeProfile;
|
final p = _activeProfile;
|
||||||
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
||||||
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
||||||
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
|
|
||||||
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
||||||
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
||||||
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
||||||
|
|
@ -130,7 +129,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
"exclusions": {
|
"exclusions": {
|
||||||
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
"processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
// No per-process exclusion field on mobile — Android's per-app
|
||||||
|
// selection (app_rules below) is the equivalent, and correct, control.
|
||||||
|
"processes": const [],
|
||||||
},
|
},
|
||||||
"app_rules": {
|
"app_rules": {
|
||||||
"mode": appRoutingMode,
|
"mode": appRoutingMode,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import '../models/ostp_profile.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 {
|
class SettingsScreen extends StatefulWidget {
|
||||||
final SharedPreferences prefs;
|
final SharedPreferences prefs;
|
||||||
const SettingsScreen({super.key, required this.prefs});
|
const SettingsScreen({super.key, required this.prefs});
|
||||||
|
|
@ -25,7 +32,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
late TextEditingController _mtuCtrl;
|
late TextEditingController _mtuCtrl;
|
||||||
late TextEditingController _domainsCtrl;
|
late TextEditingController _domainsCtrl;
|
||||||
late TextEditingController _ipsCtrl;
|
late TextEditingController _ipsCtrl;
|
||||||
late TextEditingController _processesCtrl;
|
|
||||||
late TextEditingController _muxSessionsCtrl;
|
late TextEditingController _muxSessionsCtrl;
|
||||||
|
|
||||||
bool _debugMode = false;
|
bool _debugMode = false;
|
||||||
|
|
@ -46,7 +52,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
_mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140');
|
_mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140');
|
||||||
_domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? '');
|
_domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? '');
|
||||||
_ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? '');
|
_ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? '');
|
||||||
_processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? '');
|
// 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;
|
_debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
||||||
_muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
_muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
||||||
_muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2');
|
_muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2');
|
||||||
|
|
@ -61,7 +69,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
_mtuCtrl.dispose();
|
_mtuCtrl.dispose();
|
||||||
_domainsCtrl.dispose();
|
_domainsCtrl.dispose();
|
||||||
_ipsCtrl.dispose();
|
_ipsCtrl.dispose();
|
||||||
_processesCtrl.dispose();
|
|
||||||
_muxSessionsCtrl.dispose();
|
_muxSessionsCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +79,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
widget.prefs.setString('mtu', _mtuCtrl.text.trim());
|
widget.prefs.setString('mtu', _mtuCtrl.text.trim());
|
||||||
widget.prefs.setString('ex_domains', _domainsCtrl.text.trim());
|
widget.prefs.setString('ex_domains', _domainsCtrl.text.trim());
|
||||||
widget.prefs.setString('ex_ips', _ipsCtrl.text.trim());
|
widget.prefs.setString('ex_ips', _ipsCtrl.text.trim());
|
||||||
widget.prefs.setString('ex_processes', _processesCtrl.text.trim());
|
|
||||||
widget.prefs.setBool('debug_mode', _debugMode);
|
widget.prefs.setBool('debug_mode', _debugMode);
|
||||||
widget.prefs.setBool('mux_enabled', _muxEnabled);
|
widget.prefs.setBool('mux_enabled', _muxEnabled);
|
||||||
widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim());
|
widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim());
|
||||||
|
|
@ -281,43 +287,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
// ── Junk packets + TCP fragmentation — same per-profile
|
// Junk packets + TCP fragmentation moved into their own
|
||||||
// fields/defaults as the desktop GUI's profile editor. ──
|
// modals (tap to configure) — this dialog was carrying too
|
||||||
const Text('DPI obfuscation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white54, letterSpacing: 1.0)),
|
// 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),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: TextField(controller: junkPcMinCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Junk packets (min)'))),
|
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),
|
const SizedBox(width: 12),
|
||||||
Expanded(child: TextField(controller: junkPcMaxCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Junk packets (max)'))),
|
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,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(child: TextField(controller: junkPsMinCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Junk size (min, bytes)'))),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(child: TextField(controller: junkPsMaxCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Junk size (max, bytes)'))),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
SwitchListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: const Text('TCP Fragmentation', style: TextStyle(fontSize: 14)),
|
|
||||||
subtitle: const Text('Split the handshake into small chunks', style: TextStyle(fontSize: 12, color: Colors.white54)),
|
|
||||||
value: tcpFragmentation,
|
|
||||||
onChanged: (v) => setDialogState(() => tcpFragmentation = v),
|
|
||||||
),
|
|
||||||
if (tcpFragmentation) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(child: TextField(controller: fragChunkCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Chunk size (bytes)'))),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(child: TextField(controller: fragSleepCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Delay (ms)'))),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -394,6 +395,110 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<bool> 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) {
|
void _showShareModal(OstpProfile p) {
|
||||||
final key = Uri.encodeComponent(p.accessKey);
|
final key = Uri.encodeComponent(p.accessKey);
|
||||||
if (p.serverAddr.isEmpty || p.accessKey.isEmpty) return;
|
if (p.serverAddr.isEmpty || p.accessKey.isEmpty) return;
|
||||||
|
|
@ -408,7 +513,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
title: Text('Share "${p.name}"', textAlign: TextAlign.center),
|
// 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(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -418,20 +527,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
child: QrImageView(data: url, version: QrVersions.auto, size: 200.0),
|
child: QrImageView(data: url, version: QrVersions.auto, size: 200.0),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
ElevatedButton.icon(
|
Builder(builder: (context) {
|
||||||
|
final bg = Theme.of(context).colorScheme.primary;
|
||||||
|
final fg = _onColor(bg);
|
||||||
|
return ElevatedButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Clipboard.setData(ClipboardData(text: url));
|
Clipboard.setData(ClipboardData(text: url));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Copied to clipboard')));
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Copied to clipboard')));
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.copy_rounded, color: Colors.white),
|
icon: Icon(Icons.copy_rounded, color: fg),
|
||||||
label: const Text('Copy Link', style: TextStyle(color: Colors.white)),
|
label: Text('Copy Link', style: TextStyle(color: fg)),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
backgroundColor: bg,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close'))],
|
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close'))],
|
||||||
|
|
@ -516,13 +629,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
groupValue: activeId,
|
groupValue: activeId,
|
||||||
onChanged: (_) => _selectActive(p),
|
onChanged: (_) => _selectActive(p),
|
||||||
),
|
),
|
||||||
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
subtitle: Text('${p.serverAddr} (${p.transportMode.toUpperCase()})', style: const TextStyle(fontSize: 12)),
|
// 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(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.qr_code_rounded, size: 20, color: Colors.white54),
|
icon: const Icon(Icons.share_rounded, size: 20, color: Colors.white54),
|
||||||
onPressed: () => _showShareModal(p),
|
onPressed: () => _showShareModal(p),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
@ -622,7 +747,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
),
|
),
|
||||||
_buildTextField('Bypass Domains', _domainsCtrl, hint: 'example.com\n*.google.com', maxLines: 3),
|
_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),
|
_buildTextField('Bypass IPs / CIDR', _ipsCtrl, hint: '192.168.1.0/24\n10.0.0.1', maxLines: 3),
|
||||||
_buildTextField('Bypass Processes', _processesCtrl, hint: 'com.example.app', maxLines: 3),
|
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue