- settings_provider: AppSettings (ecuType, pollingInterval) StateNotifier - bt_provider: BtNotifier (disconnected/connecting/connected/error states), btServiceProvider singleton, pairedDevicesProvider FutureProvider, internal frameStream piped through StreamController - sensor_provider: sensorStateProvider StreamProvider (auto S300/KPro), latestSensorProvider convenience Provider - theme_provider: AppThemeVariant (red/green × dark/light) StateNotifier - main.dart: fully Riverpod — ProviderScope, ConsumerWidget throughout, no setState for BT state, providers own all lifecycle
662 lines
23 KiB
Dart
662 lines
23 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
|
|
import 'core/providers/bt_provider.dart';
|
|
import 'core/providers/sensor_provider.dart';
|
|
import 'core/providers/settings_provider.dart';
|
|
import 'core/providers/theme_provider.dart';
|
|
import 'core/bluetooth/bt_poller.dart';
|
|
import 'core/protocol/sensor_state.dart';
|
|
|
|
void main() {
|
|
runApp(const ProviderScope(child: HvbtApp()));
|
|
}
|
|
|
|
class HvbtApp extends ConsumerWidget {
|
|
const HvbtApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final themeVariant = ref.watch(themeProvider);
|
|
final accent = themeVariant.accentColor;
|
|
|
|
return MaterialApp(
|
|
title: 'HV BT Dashboard',
|
|
debugShowCheckedModeBanner: false,
|
|
theme: ThemeData.dark(useMaterial3: true).copyWith(
|
|
colorScheme: ColorScheme.fromSeed(
|
|
seedColor: accent,
|
|
brightness: Brightness.dark,
|
|
),
|
|
),
|
|
home: const BtPickerScreen(),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Bluetooth Device Picker ──────────────────────────────────────────────────
|
|
|
|
class BtPickerScreen extends ConsumerStatefulWidget {
|
|
const BtPickerScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<BtPickerScreen> createState() => _BtPickerScreenState();
|
|
}
|
|
|
|
class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
|
|
String? _error;
|
|
bool _initDone = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth());
|
|
}
|
|
|
|
Future<void> _initBluetooth() async {
|
|
setState(() => _error = null);
|
|
|
|
// 1. Runtime permissions
|
|
final statuses = await [
|
|
Permission.bluetoothConnect,
|
|
Permission.bluetoothScan,
|
|
Permission.locationWhenInUse,
|
|
].request();
|
|
|
|
final denied = statuses.entries
|
|
.where((e) => e.value.isDenied || e.value.isPermanentlyDenied)
|
|
.map((e) => e.key.toString())
|
|
.toList();
|
|
|
|
if (denied.isNotEmpty) {
|
|
setState(() {
|
|
_error = 'Permissions denied: ${denied.join(", ")}\n\n'
|
|
'Go to Settings → Apps → HV BT Dashboard → Permissions '
|
|
'and allow Bluetooth + Location, then tap Refresh.';
|
|
_initDone = true;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 2. Auto-enable Bluetooth
|
|
final btState = await FlutterBluetoothSerial.instance.state;
|
|
if (btState != BluetoothState.STATE_ON) {
|
|
await FlutterBluetoothSerial.instance.requestEnable();
|
|
await Future<void>.delayed(const Duration(seconds: 1));
|
|
}
|
|
|
|
setState(() => _initDone = true);
|
|
ref.invalidate(pairedDevicesProvider);
|
|
}
|
|
|
|
Future<void> _connect(BluetoothDevice device, EcuType ecuType) async {
|
|
// Set protocol before connecting so the poller uses it
|
|
ref.read(settingsProvider.notifier).setEcuType(ecuType);
|
|
await ref.read(btProvider.notifier).connect(device);
|
|
|
|
if (!mounted) return;
|
|
final btState = ref.read(btProvider);
|
|
if (btState.status == BtStatus.connected) {
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(builder: (_) => const LiveDataScreen()),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _showProtocolDialog(BluetoothDevice device) {
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(device.name ?? device.address),
|
|
content: const Text('Select ECU protocol:'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () { Navigator.pop(ctx); _connect(device, EcuType.s300); },
|
|
child: const Text('S300'),
|
|
),
|
|
TextButton(
|
|
onPressed: () { Navigator.pop(ctx); _connect(device, EcuType.kpro); },
|
|
child: const Text('KPro'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
child: const Text('Cancel'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final btState = ref.watch(btProvider);
|
|
final devicesAsync = ref.watch(pairedDevicesProvider);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('HV BT — Select Device'),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: btState.isConnecting
|
|
? null
|
|
: () { setState(() => _error = null); _initBluetooth(); },
|
|
tooltip: 'Refresh',
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
// BT connection error banner
|
|
if (btState.status == BtStatus.error)
|
|
_ErrorBanner(
|
|
message: 'Connection failed: ${btState.error}',
|
|
onDismiss: () =>
|
|
ref.read(btProvider.notifier).disconnect(),
|
|
),
|
|
|
|
// Permission / init error banner
|
|
if (_error != null)
|
|
_ErrorBanner(
|
|
message: _error!,
|
|
onDismiss: () => setState(() => _error = null),
|
|
),
|
|
|
|
Expanded(
|
|
child: !_initDone
|
|
? const Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(),
|
|
SizedBox(height: 12),
|
|
Text('Requesting permissions…'),
|
|
],
|
|
),
|
|
)
|
|
: devicesAsync.when(
|
|
loading: () => const Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(),
|
|
SizedBox(height: 12),
|
|
Text('Loading paired devices…'),
|
|
],
|
|
),
|
|
),
|
|
error: (e, _) => Center(
|
|
child: _ErrorBanner(
|
|
message: 'Failed to load devices:\n$e',
|
|
onDismiss: () => ref.refresh(pairedDevicesProvider),
|
|
),
|
|
),
|
|
data: (devices) => devices.isEmpty
|
|
? const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.bluetooth_disabled,
|
|
size: 64, color: Colors.grey),
|
|
SizedBox(height: 16),
|
|
Text('No paired BT devices found.',
|
|
style: TextStyle(fontSize: 16)),
|
|
SizedBox(height: 8),
|
|
Text(
|
|
'Pair your ECU module in Android Settings → Bluetooth, then tap Refresh.',
|
|
style: TextStyle(color: Colors.grey),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
: ListView.separated(
|
|
itemCount: devices.length,
|
|
separatorBuilder: (_, __) =>
|
|
const Divider(height: 1),
|
|
itemBuilder: (context, i) {
|
|
final device = devices[i];
|
|
final isConnecting = btState.isConnecting &&
|
|
btState.deviceName == (device.name ?? device.address);
|
|
return ListTile(
|
|
leading: Icon(Icons.bluetooth,
|
|
color: isConnecting
|
|
? Colors.blue
|
|
: Colors.grey),
|
|
title: Text(device.name ?? 'Unknown'),
|
|
subtitle: Text(device.address),
|
|
trailing: isConnecting
|
|
? const SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.arrow_forward_ios,
|
|
size: 16, color: Colors.grey),
|
|
onTap: btState.isConnecting
|
|
? null
|
|
: () => _showProtocolDialog(device),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Live Data Screen ─────────────────────────────────────────────────────────
|
|
|
|
class LiveDataScreen extends ConsumerWidget {
|
|
const LiveDataScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final btState = ref.watch(btProvider);
|
|
final sensorAsync = ref.watch(sensorStateProvider);
|
|
final settings = ref.watch(settingsProvider);
|
|
final protocol = settings.ecuType == EcuType.s300 ? 'S300' : 'KPro';
|
|
|
|
return DefaultTabController(
|
|
length: 2,
|
|
child: Scaffold(
|
|
backgroundColor: const Color(0xFF0A0A0A),
|
|
appBar: AppBar(
|
|
backgroundColor: const Color(0xFF1A1A1A),
|
|
title: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(btState.deviceName ?? 'ECU',
|
|
style: const TextStyle(fontSize: 15)),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
btState.isConnected ? Icons.circle : Icons.hourglass_empty,
|
|
size: 9,
|
|
color: btState.isConnected ? Colors.green : Colors.orange,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'$protocol • frames: ${btState.frameCount} • dropped: ${btState.droppedFrames}',
|
|
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.bluetooth_disabled, color: Colors.redAccent),
|
|
tooltip: 'Disconnect',
|
|
onPressed: () async {
|
|
await ref.read(btProvider.notifier).disconnect();
|
|
if (context.mounted) {
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(builder: (_) => const BtPickerScreen()),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
bottom: const TabBar(
|
|
tabs: [Tab(text: 'LIVE DATA'), Tab(text: 'DEBUG LOG')],
|
|
),
|
|
),
|
|
body: TabBarView(
|
|
children: [
|
|
// ── Tab 1: Live sensor data ──
|
|
sensorAsync.when(
|
|
loading: () => const _WaitingForData(),
|
|
error: (e, _) => _ErrorCenter(message: e.toString()),
|
|
data: (state) => _SensorGrid(
|
|
state: state,
|
|
frameCount: btState.frameCount,
|
|
),
|
|
),
|
|
|
|
// ── Tab 2: Error/status log ──
|
|
_DebugTab(btState: btState, sensorAsync: sensorAsync),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Waiting / Error states ───────────────────────────────────────────────────
|
|
|
|
class _WaitingForData extends StatelessWidget {
|
|
const _WaitingForData();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return const Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(color: Colors.orange),
|
|
SizedBox(height: 20),
|
|
Text('Waiting for ECU response…', style: TextStyle(fontSize: 16)),
|
|
SizedBox(height: 8),
|
|
Text(
|
|
'Sending request every 100ms\nMake sure ECU is powered on',
|
|
style: TextStyle(color: Colors.grey, fontSize: 13),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
SizedBox(height: 24),
|
|
Text('Check DEBUG LOG tab for details',
|
|
style: TextStyle(color: Colors.orange, fontSize: 13)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ErrorCenter extends StatelessWidget {
|
|
final String message;
|
|
const _ErrorCenter({required this.message});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
|
const SizedBox(height: 12),
|
|
Text(message,
|
|
style: const TextStyle(color: Colors.redAccent),
|
|
textAlign: TextAlign.center),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Debug Tab ────────────────────────────────────────────────────────────────
|
|
|
|
class _DebugTab extends StatelessWidget {
|
|
final BtState btState;
|
|
final AsyncValue<SensorState> sensorAsync;
|
|
|
|
const _DebugTab({required this.btState, required this.sensorAsync});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final lines = <_DebugLine>[];
|
|
|
|
lines.add(_DebugLine(
|
|
icon: btState.isConnected ? Icons.check_circle_outline : Icons.info_outline,
|
|
color: btState.isConnected ? Colors.green : Colors.orange,
|
|
text: 'BT: ${btState.status.name.toUpperCase()} '
|
|
'${btState.deviceName != null ? "→ ${btState.deviceName}" : ""}',
|
|
));
|
|
|
|
if (btState.error != null) {
|
|
lines.add(_DebugLine(
|
|
icon: Icons.error_outline,
|
|
color: Colors.red,
|
|
text: 'Error: ${btState.error}'));
|
|
}
|
|
|
|
lines.add(_DebugLine(
|
|
icon: Icons.analytics_outlined,
|
|
color: Colors.grey,
|
|
text:
|
|
'Frames: ${btState.frameCount} Dropped: ${btState.droppedFrames} '
|
|
'Rate: ${btState.frameCount > 0 ? "~10 Hz" : "0 Hz"}',
|
|
));
|
|
|
|
sensorAsync.whenOrNull(
|
|
error: (e, _) => lines.add(_DebugLine(
|
|
icon: Icons.error_outline, color: Colors.red, text: 'Parse error: $e')),
|
|
data: (s) => lines.add(_DebugLine(
|
|
icon: Icons.check_circle_outline,
|
|
color: Colors.green,
|
|
text: 'Last frame: RPM=${s.rpm.toStringAsFixed(0)} '
|
|
'ECT=${s.ect.toStringAsFixed(1)}°C '
|
|
'BAT=${s.bat.toStringAsFixed(2)}V')),
|
|
);
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.all(12),
|
|
children: lines
|
|
.map((l) => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 5),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(l.icon, size: 15, color: l.color),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(l.text,
|
|
style: TextStyle(
|
|
color: l.color.withAlpha(220), fontSize: 12)),
|
|
),
|
|
],
|
|
),
|
|
))
|
|
.toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DebugLine {
|
|
final IconData icon;
|
|
final Color color;
|
|
final String text;
|
|
const _DebugLine({required this.icon, required this.color, required this.text});
|
|
}
|
|
|
|
// ─── Sensor Grid ──────────────────────────────────────────────────────────────
|
|
|
|
class _SensorGrid extends StatelessWidget {
|
|
final SensorState state;
|
|
final int frameCount;
|
|
|
|
const _SensorGrid({required this.state, required this.frameCount});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
children: [
|
|
_BigValue(
|
|
label: 'RPM',
|
|
value: state.rpm.toStringAsFixed(0),
|
|
unit: 'rpm',
|
|
color: _rpmColor(state.rpm),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 6,
|
|
children: [
|
|
_FlagPill('MIL', state.mil, Colors.amber),
|
|
_FlagPill('VTEC', state.vtec, Colors.blue),
|
|
_FlagPill('KNOCK', state.knock, Colors.red),
|
|
_FlagPill('FUEL CUT', state.fuelCut, Colors.red),
|
|
_FlagPill('REV LIM', state.revLimit, Colors.orange),
|
|
_FlagPill('FAN', state.fanOut, Colors.green),
|
|
_FlagPill('LAUNCH', state.launch, Colors.purple),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
GridView.count(
|
|
crossAxisCount: 2,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
mainAxisSpacing: 6,
|
|
crossAxisSpacing: 6,
|
|
childAspectRatio: 2.4,
|
|
children: [
|
|
_Tile('VSS', '${state.vss.toStringAsFixed(1)} km/h'),
|
|
_Tile('MAP', '${state.map.toStringAsFixed(1)} kPa'),
|
|
_Tile('TPS', '${state.tps.toStringAsFixed(1)} %'),
|
|
_Tile('ECT', '${state.ect.toStringAsFixed(1)} °C'),
|
|
_Tile('IAT', '${state.iat.toStringAsFixed(1)} °C'),
|
|
_Tile('BAT', '${state.bat.toStringAsFixed(2)} V'),
|
|
_Tile('IGN', '${state.ign.toStringAsFixed(1)} °'),
|
|
_Tile('INJ', '${state.inj.toStringAsFixed(2)} ms'),
|
|
_Tile('O2', state.o2.toStringAsFixed(0)),
|
|
_Tile('AFR', state.afr.toStringAsFixed(2)),
|
|
_Tile('ETH', '${state.eth.toStringAsFixed(0)} %'),
|
|
_Tile('GEAR', state.gear.toString()),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text('Frames: $frameCount',
|
|
style: const TextStyle(color: Colors.grey, fontSize: 11)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _rpmColor(double rpm) {
|
|
if (rpm > 7000) return Colors.red;
|
|
if (rpm > 5500) return Colors.orange;
|
|
return const Color(0xFF00E676);
|
|
}
|
|
}
|
|
|
|
// ─── Shared UI widgets ────────────────────────────────────────────────────────
|
|
|
|
class _BigValue extends StatelessWidget {
|
|
final String label, value, unit;
|
|
final Color color;
|
|
const _BigValue(
|
|
{required this.label,
|
|
required this.value,
|
|
required this.unit,
|
|
required this.color});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF1A1A1A),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: color.withAlpha(120), width: 1.5),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Text(label,
|
|
style: TextStyle(
|
|
color: color, fontSize: 12, letterSpacing: 2,
|
|
fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 2),
|
|
Text(value,
|
|
style: TextStyle(color: color, fontSize: 48,
|
|
fontWeight: FontWeight.bold, letterSpacing: -2)),
|
|
Text(unit,
|
|
style: TextStyle(color: color.withAlpha(160), fontSize: 12)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Tile extends StatelessWidget {
|
|
final String label, value;
|
|
const _Tile(this.label, this.value);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF1A1A1A),
|
|
borderRadius: BorderRadius.circular(8)),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(label,
|
|
style: const TextStyle(
|
|
color: Colors.grey, fontSize: 11, letterSpacing: 1)),
|
|
Text(value,
|
|
style: const TextStyle(
|
|
color: Colors.white, fontSize: 14,
|
|
fontWeight: FontWeight.w600)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FlagPill extends StatelessWidget {
|
|
final String label;
|
|
final bool active;
|
|
final Color color;
|
|
const _FlagPill(this.label, this.active, this.color);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 120),
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: active ? color.withAlpha(40) : const Color(0xFF1A1A1A),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: active ? color : Colors.grey.withAlpha(60), width: 1.5),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: active ? color : Colors.grey,
|
|
fontSize: 11,
|
|
fontWeight: active ? FontWeight.bold : FontWeight.normal,
|
|
letterSpacing: 1),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ErrorBanner extends StatelessWidget {
|
|
final String message;
|
|
final VoidCallback onDismiss;
|
|
const _ErrorBanner({required this.message, required this.onDismiss});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
color: const Color(0xFF4A0000),
|
|
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Icon(Icons.error_outline, color: Colors.redAccent, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(message,
|
|
style: const TextStyle(
|
|
color: Colors.redAccent, fontSize: 12))),
|
|
IconButton(
|
|
icon: const Icon(Icons.close, color: Colors.redAccent, size: 18),
|
|
onPressed: onDismiss,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|