obd_settings_card.dart 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  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 '../home/obd_controller.dart';
  8. /// Settings card for the optional Bluetooth Classic ELM327 adapter only.
  9. class ObdSettingsCard extends StatefulWidget {
  10. const ObdSettingsCard({super.key});
  11. @override
  12. State<ObdSettingsCard> createState() => _ObdSettingsCardState();
  13. }
  14. class _ObdSettingsCardState extends State<ObdSettingsCard>
  15. with WidgetsBindingObserver {
  16. @override
  17. void initState() {
  18. super.initState();
  19. WidgetsBinding.instance.addObserver(this);
  20. WidgetsBinding.instance.addPostFrameCallback((_) {
  21. if (!mounted) return;
  22. unawaited(
  23. context.read<ObdController>().refreshDevices(requestPermission: false),
  24. );
  25. });
  26. }
  27. @override
  28. void dispose() {
  29. WidgetsBinding.instance.removeObserver(this);
  30. super.dispose();
  31. }
  32. @override
  33. void didChangeAppLifecycleState(AppLifecycleState state) {
  34. if (state == AppLifecycleState.resumed) {
  35. unawaited(
  36. context.read<ObdController>().refreshDevices(requestPermission: false),
  37. );
  38. }
  39. }
  40. @override
  41. Widget build(BuildContext context) {
  42. final colors = context.colors;
  43. final theme = Theme.of(context);
  44. final obd = context.watch<ObdController>();
  45. final selected = obd.selectedAddress;
  46. final options = <BluetoothDeviceInfo>[...obd.devices];
  47. if (selected != null && !options.any((d) => d.address == selected)) {
  48. options.add(
  49. BluetoothDeviceInfo(name: 'Saved adapter', address: selected),
  50. );
  51. }
  52. final selectedInOptions = options.any((d) => d.address == selected);
  53. final dropdownValue = selectedInOptions ? selected : null;
  54. final statusColor = obd.connected
  55. ? colors.accent
  56. : obd.error != null
  57. ? colors.danger
  58. : colors.inkFaint;
  59. return Column(
  60. crossAxisAlignment: CrossAxisAlignment.start,
  61. children: [
  62. Row(
  63. children: [
  64. Container(
  65. width: 42,
  66. height: 42,
  67. decoration: BoxDecoration(
  68. color: colors.accentSoft,
  69. borderRadius: BorderRadius.circular(14),
  70. ),
  71. alignment: Alignment.center,
  72. child: AppIcon(
  73. 'assets/icons/car.svg',
  74. size: 20,
  75. color: colors.accent,
  76. ),
  77. ),
  78. const SizedBox(width: 14),
  79. Expanded(
  80. child: Column(
  81. crossAxisAlignment: CrossAxisAlignment.start,
  82. children: [
  83. Text('OBD-II adapter', style: theme.textTheme.titleMedium),
  84. const SizedBox(height: 2),
  85. Row(
  86. children: [
  87. Container(
  88. width: 7,
  89. height: 7,
  90. decoration: BoxDecoration(
  91. color: statusColor,
  92. shape: BoxShape.circle,
  93. ),
  94. ),
  95. const SizedBox(width: 7),
  96. Expanded(
  97. child: Text(
  98. obd.statusLabel,
  99. maxLines: 1,
  100. overflow: TextOverflow.ellipsis,
  101. style: theme.textTheme.bodySmall?.copyWith(
  102. color: colors.inkMuted,
  103. ),
  104. ),
  105. ),
  106. ],
  107. ),
  108. ],
  109. ),
  110. ),
  111. TextButton.icon(
  112. onPressed: obd.loadingDevices
  113. ? null
  114. : () async {
  115. final ok = await obd.refreshDevices(
  116. requestPermission: true,
  117. );
  118. if (!ok && context.mounted) {
  119. _message(
  120. context,
  121. obd.devicesError ??
  122. 'Could not read paired Bluetooth devices.',
  123. );
  124. }
  125. },
  126. icon: AppIcon(
  127. 'assets/icons/search.svg',
  128. size: 16,
  129. color: colors.accent,
  130. ),
  131. label: Text(obd.needsBluetoothPermission ? 'Allow' : 'Find'),
  132. style: TextButton.styleFrom(foregroundColor: colors.accent),
  133. ),
  134. ],
  135. ),
  136. const SizedBox(height: 12),
  137. DropdownButtonFormField<String?>(
  138. key: ValueKey(dropdownValue),
  139. initialValue: dropdownValue,
  140. isExpanded: true,
  141. dropdownColor: colors.surface,
  142. style: theme.textTheme.bodyLarge?.copyWith(color: colors.ink),
  143. decoration: const InputDecoration(
  144. labelText: 'Bluetooth device',
  145. isDense: true,
  146. ),
  147. items: [
  148. const DropdownMenuItem<String?>(value: null, child: Text('None')),
  149. for (final device in options)
  150. DropdownMenuItem<String?>(
  151. value: device.address,
  152. child: Text(
  153. device.label,
  154. maxLines: 1,
  155. overflow: TextOverflow.ellipsis,
  156. ),
  157. ),
  158. ],
  159. onChanged: (value) async {
  160. await obd.selectAdapter(value);
  161. if (!context.mounted) return;
  162. if (obd.error != null) {
  163. _message(context, obd.error!);
  164. }
  165. },
  166. ),
  167. if (obd.devicesError != null && !obd.needsBluetoothPermission) ...[
  168. const SizedBox(height: 6),
  169. Text(
  170. obd.devicesError!,
  171. style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
  172. ),
  173. ],
  174. if (obd.bluetoothEnabled == false) ...[
  175. const SizedBox(height: 2),
  176. TextButton.icon(
  177. onPressed: () async {
  178. final opened = await obd.openBluetoothSettings();
  179. if (!opened && context.mounted) {
  180. _message(context, 'Could not open Bluetooth settings.');
  181. }
  182. },
  183. icon: AppIcon(
  184. 'assets/icons/settings.svg',
  185. size: 16,
  186. color: colors.accent,
  187. ),
  188. label: const Text('Open Bluetooth settings'),
  189. style: TextButton.styleFrom(
  190. foregroundColor: colors.accent,
  191. padding: EdgeInsets.zero,
  192. alignment: Alignment.centerLeft,
  193. ),
  194. ),
  195. ],
  196. const SizedBox(height: 8),
  197. Text(
  198. 'Pair the ELM327 in Android Bluetooth settings first. CarDash only '
  199. 'reads standard OBD-II PIDs; it never writes.',
  200. style: theme.textTheme.bodySmall?.copyWith(color: colors.inkMuted),
  201. ),
  202. ],
  203. );
  204. }
  205. void _message(BuildContext context, String message) {
  206. final messenger = ScaffoldMessenger.of(context);
  207. messenger.hideCurrentSnackBar();
  208. messenger.showSnackBar(SnackBar(content: Text(message)));
  209. }
  210. }