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:
ospab 2026-07-10 00:45:30 +03:00
parent 7d9e5faeec
commit 1b3390a3cf
4 changed files with 226 additions and 77 deletions

View File

@ -96,24 +96,35 @@ class MainActivity : FlutterActivity() {
} }
} }
"getInstalledApps" -> { "getInstalledApps" -> {
try { // MethodChannel handlers run on the main/UI thread by default.
val pm = packageManager // Enumerating every installed package AND decoding+re-encoding
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA) // each one's icon to PNG/base64 is expensive (100+ apps is
val list = apps.map { app -> // common) — done inline here it blocked the main thread for
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) && // 10-15s, during which Flutter couldn't render ANY frame, not
(pm.getLaunchIntentForPackage(app.packageName) == null) // even the "loading" spinner, so the screen just appeared to
val iconBase64 = getAppIconBase64(pm, app) // hang before jumping straight to the fully-loaded list.
mapOf( // Do the work on a background thread; only the final
"name" to pm.getApplicationLabel(app).toString(), // `result.success(...)` needs to hop back onto the UI thread.
"package" to app.packageName, val pm = packageManager
"isSystem" to isSystem, Thread {
"icon" to (iconBase64 ?: "") try {
) val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
val list = apps.map { app ->
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
(pm.getLaunchIntentForPackage(app.packageName) == null)
val iconBase64 = getAppIconBase64(pm, app)
mapOf(
"name" to pm.getApplicationLabel(app).toString(),
"package" to app.packageName,
"isSystem" to isSystem,
"icon" to (iconBase64 ?: "")
)
}
runOnUiThread { result.success(list) }
} catch (e: Exception) {
runOnUiThread { result.error("ERROR", e.message, null) }
} }
result.success(list) }.start()
} catch (e: Exception) {
result.error("ERROR", e.message, null)
}
} }
else -> result.notImplemented() else -> result.notImplemented()
} }

View File

@ -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,
),
), ),
), ),
), ),

View File

@ -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,

View File

@ -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) {
onPressed: () { final bg = Theme.of(context).colorScheme.primary;
Clipboard.setData(ClipboardData(text: url)); final fg = _onColor(bg);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Copied to clipboard'))); return ElevatedButton.icon(
Navigator.pop(context); onPressed: () {
}, Clipboard.setData(ClipboardData(text: url));
icon: const Icon(Icons.copy_rounded, color: Colors.white), ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Copied to clipboard')));
label: const Text('Copy Link', style: TextStyle(color: Colors.white)), Navigator.pop(context);
style: ElevatedButton.styleFrom( },
backgroundColor: Theme.of(context).colorScheme.primary, icon: Icon(Icons.copy_rounded, color: fg),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), label: Text('Copy Link', style: TextStyle(color: fg)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), 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'))], 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(