hondavert-dev/lib/ui/screens/dtc/dtc_screen.dart
2026-10-13 00:34:11 +00:00

233 lines
7.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/protocol/dtc_map.dart';
import '../../../core/providers/bt_provider.dart';
import '../../../core/providers/sensor_provider.dart';
import '../../../core/providers/settings_provider.dart';
import '../../../core/bluetooth/bt_poller.dart';
import 'widgets/dtc_row.dart';
class DtcScreen extends ConsumerStatefulWidget {
const DtcScreen({super.key});
@override
ConsumerState<DtcScreen> createState() => _DtcScreenState();
}
class _DtcScreenState extends ConsumerState<DtcScreen> {
bool _clearing = false;
Uint8List? _lastRaw;
String _hex(Uint8List bytes) {
final parts = <String>[];
for (var i = 0; i < bytes.length; i++) {
if (i > 0 && i % 16 == 0) parts.add('\n');
parts.add(bytes[i].toRadixString(16).padLeft(2, '0'));
}
return parts.join(' ');
}
void _showRawCapture() {
final raw = _lastRaw;
if (raw == null) return;
final hex = _hex(raw);
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text('Raw capture (${raw.length} bytes)'),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: SelectableText(
hex,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
),
),
actions: [
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: hex));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied')),
);
}
},
child: const Text('Copy'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Future<void> _confirmAndClear() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Trouble Codes'),
content: const Text(
'This sends a reset command to the ECU and erases all stored '
'fault codes. This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Clear',
style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed != true) return;
setState(() => _clearing = true);
final result = await ref.read(btProvider.notifier).clearDtcs();
if (!mounted) return;
setState(() {
_clearing = false;
_lastRaw = result.raw;
});
final String message;
if (result.acknowledged) {
message = 'Trouble codes cleared';
} else if (result.raw.isEmpty) {
message = 'No response from ECU — codes may not have cleared';
} else {
// The scan runs alongside normal polling now, so "not acknowledged"
// can mean several seconds' worth of regular datalog traffic with no
// ack byte pattern found in it — dumping all of that raw would be
// unusable in a snackbar, so just report how much was seen. "View"
// opens the full hex to check by hand for anything our narrow 3-byte
// pattern match might have missed.
message =
'No ack seen — ${result.raw.length} bytes of other traffic during the window';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
action: (!result.acknowledged && result.raw.isNotEmpty)
? SnackBarAction(label: 'View', onPressed: _showRawCapture)
: null,
duration: const Duration(seconds: 6),
),
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(latestSensorProvider);
final bt = ref.watch(btProvider);
final ecuType = ref.watch(settingsProvider).ecuType;
final dtcMap =
ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap;
final active = decodeDtcs(state.errBytes, dtcMap);
return Column(
children: [
// Header bar
Container(
color: const Color(0xFF1A1A1A),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Icon(
active.isEmpty
? Icons.check_circle_outline
: Icons.warning_amber_rounded,
color: active.isEmpty ? Colors.green : Colors.amber,
size: 18,
),
const SizedBox(width: 8),
Text(
active.isEmpty
? 'No active fault codes'
: '${active.length} active DTC${active.length > 1 ? "s" : ""}',
style: TextStyle(
color: active.isEmpty ? Colors.green : Colors.amber,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Text(
ecuType == EcuType.s300 ? 'S300' : 'KPro',
style:
const TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
const Divider(height: 1, color: Colors.white10),
// DTC list
Expanded(
child: active.isEmpty
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline,
size: 56, color: Colors.green),
SizedBox(height: 12),
Text('All clear — no fault codes',
style: TextStyle(
color: Colors.grey, fontSize: 15)),
],
),
)
: ListView.separated(
itemCount: active.length,
separatorBuilder: (_, __) =>
const Divider(height: 1, color: Colors.white10),
itemBuilder: (context, i) {
final full = active[i]; // "P0130 — O2 Sensor (front)"
final parts = full.split(' — ');
final code = parts.first.trim();
final desc =
parts.length > 1 ? parts.sublist(1).join(' — ') : '';
return DtcRow(code: code, description: desc);
},
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: (bt.isConnected && !_clearing)
? _confirmAndClear
: null,
child: Text(
_clearing ? 'Clearing…' : 'CLEAR TROUBLE CODES',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
),
),
],
);
}
}