hondavert-dev/lib/main.dart
HVBT Dev 6a80d8fc1f v1.2.0 — Phase 2: Riverpod providers wired up
- 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
2026-04-13 20:13:32 +05:30

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(),
),
],
),
);
}
}