obd_settings_card.dart 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:provider/provider.dart';
  4. import '../../core/theme/app_colors.dart';
  5. import '../../core/widgets/app_icon.dart';
  6. import '../../data/models/bluetooth_device_info.dart';
  7. import '../../data/models/bluetooth_power_state.dart';
  8. import '../home/obd_controller.dart';
  9. /// Settings card for the optional Bluetooth Classic ELM327 adapter only.
  10. class ObdSettingsCard extends StatefulWidget {
  11. const ObdSettingsCard({super.key});
  12. @override
  13. State<ObdSettingsCard> createState() => _ObdSettingsCardState();
  14. }
  15. class _ObdSettingsCardState extends State<ObdSettingsCard>
  16. with WidgetsBindingObserver {
  17. bool _showAllDevices = false;
  18. @override
  19. void initState() {
  20. super.initState();
  21. WidgetsBinding.instance.addObserver(this);
  22. WidgetsBinding.instance.addPostFrameCallback((_) {
  23. if (!mounted) return;
  24. unawaited(
  25. context.read<ObdController>().refreshDevices(
  26. requestPermission: false,
  27. force: true,
  28. ),
  29. );
  30. });
  31. }
  32. @override
  33. void dispose() {
  34. WidgetsBinding.instance.removeObserver(this);
  35. super.dispose();
  36. }
  37. @override
  38. void didChangeAppLifecycleState(AppLifecycleState state) {
  39. if (state != AppLifecycleState.resumed || !mounted) return;
  40. // The controller performs a cheap power-state check. It only lists bonded
  41. // devices again when the cached list is missing, so returning from Android
  42. // Settings does not hit the Bluetooth stack with a bond query every time.
  43. unawaited(context.read<ObdController>().refreshDevices(dueToResume: true));
  44. }
  45. @override
  46. Widget build(BuildContext context) {
  47. final colors = context.colors;
  48. final theme = Theme.of(context);
  49. final obd = context.watch<ObdController>();
  50. final selected = obd.selectedAddress;
  51. final options = <BluetoothDeviceInfo>[
  52. ...obd.obdDevices,
  53. if (_showAllDevices) ...obd.otherDevices,
  54. ];
  55. if (selected != null && !options.any((d) => d.address == selected)) {
  56. options.add(
  57. BluetoothDeviceInfo(name: 'Saved adapter', address: selected),
  58. );
  59. }
  60. final selectedInOptions = options.any((d) => d.address == selected);
  61. final dropdownValue = selectedInOptions ? selected : null;
  62. final statusColor = obd.connected
  63. ? colors.accent
  64. : obd.error != null || obd.bluetoothState == BluetoothPowerState.unknown
  65. ? colors.danger
  66. : colors.inkFaint;
  67. return Column(
  68. crossAxisAlignment: CrossAxisAlignment.start,
  69. children: [
  70. Row(
  71. children: [
  72. Container(
  73. width: 42,
  74. height: 42,
  75. decoration: BoxDecoration(
  76. color: colors.accentSoft,
  77. borderRadius: BorderRadius.circular(14),
  78. ),
  79. alignment: Alignment.center,
  80. child: AppIcon(
  81. 'assets/icons/car.svg',
  82. size: 20,
  83. color: colors.accent,
  84. ),
  85. ),
  86. const SizedBox(width: 14),
  87. Expanded(
  88. child: Column(
  89. crossAxisAlignment: CrossAxisAlignment.start,
  90. children: [
  91. Text('OBD-II adapter', style: theme.textTheme.titleMedium),
  92. const SizedBox(height: 2),
  93. Row(
  94. children: [
  95. Container(
  96. width: 7,
  97. height: 7,
  98. decoration: BoxDecoration(
  99. color: statusColor,
  100. shape: BoxShape.circle,
  101. ),
  102. ),
  103. const SizedBox(width: 7),
  104. Expanded(
  105. child: Text(
  106. obd.statusLabel,
  107. maxLines: 1,
  108. overflow: TextOverflow.ellipsis,
  109. style: theme.textTheme.bodySmall?.copyWith(
  110. color: colors.inkMuted,
  111. ),
  112. ),
  113. ),
  114. ],
  115. ),
  116. ],
  117. ),
  118. ),
  119. TextButton.icon(
  120. onPressed: obd.loadingDevices
  121. ? null
  122. : () async {
  123. final ok = await obd.refreshDevices(
  124. requestPermission: true,
  125. force: true,
  126. );
  127. if (!ok && context.mounted) {
  128. _message(
  129. context,
  130. obd.devicesError ??
  131. 'Could not read paired Bluetooth devices.',
  132. );
  133. }
  134. },
  135. icon: AppIcon(
  136. 'assets/icons/search.svg',
  137. size: 16,
  138. color: colors.accent,
  139. ),
  140. label: Text(obd.needsBluetoothPermission ? 'Allow' : 'Find'),
  141. style: TextButton.styleFrom(foregroundColor: colors.accent),
  142. ),
  143. ],
  144. ),
  145. const SizedBox(height: 12),
  146. DropdownButtonFormField<String?>(
  147. key: ValueKey('${dropdownValue ?? 'none'}-$_showAllDevices'),
  148. initialValue: dropdownValue,
  149. isExpanded: true,
  150. dropdownColor: colors.surface,
  151. style: theme.textTheme.bodyLarge?.copyWith(color: colors.ink),
  152. decoration: const InputDecoration(
  153. labelText: 'Bluetooth device',
  154. isDense: true,
  155. ),
  156. items: [
  157. const DropdownMenuItem<String?>(value: null, child: Text('None')),
  158. for (final device in options)
  159. DropdownMenuItem<String?>(
  160. value: device.address,
  161. child: Text(
  162. device.label,
  163. maxLines: 1,
  164. overflow: TextOverflow.ellipsis,
  165. ),
  166. ),
  167. ],
  168. onChanged: obd.loadingDevices
  169. ? null
  170. : (value) async {
  171. await obd.selectAdapter(value);
  172. if (!context.mounted) return;
  173. if (obd.error != null) {
  174. _message(context, obd.error!);
  175. }
  176. },
  177. ),
  178. if (obd.otherDevices.isNotEmpty) ...[
  179. const SizedBox(height: 2),
  180. TextButton(
  181. onPressed: () {
  182. setState(() => _showAllDevices = !_showAllDevices);
  183. },
  184. style: TextButton.styleFrom(
  185. foregroundColor: colors.accent,
  186. padding: EdgeInsets.zero,
  187. alignment: Alignment.centerLeft,
  188. ),
  189. child: Text(
  190. _showAllDevices
  191. ? 'Show only OBD-II adapters'
  192. : 'Show all paired devices',
  193. ),
  194. ),
  195. if (_showAllDevices)
  196. Text(
  197. 'Phones, headsets and other devices usually cannot act as an '
  198. 'OBD-II adapter.',
  199. style: theme.textTheme.bodySmall?.copyWith(
  200. color: colors.inkMuted,
  201. ),
  202. ),
  203. ],
  204. if (obd.canRetry) ...[
  205. const SizedBox(height: 2),
  206. TextButton(
  207. onPressed: () async {
  208. await obd.retryConnection();
  209. if (!context.mounted) return;
  210. if (obd.error != null) {
  211. _message(context, obd.error!);
  212. }
  213. },
  214. style: TextButton.styleFrom(
  215. foregroundColor: colors.accent,
  216. padding: EdgeInsets.zero,
  217. alignment: Alignment.centerLeft,
  218. ),
  219. child: const Text('Retry connection'),
  220. ),
  221. ],
  222. if (obd.devicesError != null &&
  223. !obd.needsBluetoothPermission &&
  224. (obd.bluetoothState.isOn ||
  225. obd.bluetoothState == BluetoothPowerState.unavailable)) ...[
  226. const SizedBox(height: 6),
  227. Text(
  228. obd.devicesError!,
  229. style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
  230. ),
  231. ],
  232. if (obd.bluetoothOff) ...[
  233. const SizedBox(height: 2),
  234. TextButton.icon(
  235. onPressed: () async {
  236. final opened = await obd.openBluetoothSettings();
  237. if (!opened && context.mounted) {
  238. _message(context, 'Could not open Bluetooth settings.');
  239. }
  240. },
  241. icon: AppIcon(
  242. 'assets/icons/settings.svg',
  243. size: 16,
  244. color: colors.accent,
  245. ),
  246. label: const Text('Open Bluetooth settings'),
  247. style: TextButton.styleFrom(
  248. foregroundColor: colors.accent,
  249. padding: EdgeInsets.zero,
  250. alignment: Alignment.centerLeft,
  251. ),
  252. ),
  253. ],
  254. const SizedBox(height: 8),
  255. Text(
  256. 'Only Bluetooth Classic OBD-II adapters are listed. Pair the ELM327 '
  257. 'in Android Bluetooth settings first. CarDash only reads standard '
  258. 'OBD-II PIDs; it never writes.',
  259. style: theme.textTheme.bodySmall?.copyWith(color: colors.inkMuted),
  260. ),
  261. ],
  262. );
  263. }
  264. void _message(BuildContext context, String message) {
  265. final messenger = ScaffoldMessenger.of(context);
  266. messenger.hideCurrentSnackBar();
  267. messenger.showSnackBar(SnackBar(content: Text(message)));
  268. }
  269. }