car_data_section.dart 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  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. import '../home/weather_controller.dart';
  9. /// Settings card for the optional ELM327 adapter and location weather.
  10. class CarDataSection extends StatefulWidget {
  11. const CarDataSection({super.key});
  12. @override
  13. State<CarDataSection> createState() => _CarDataSectionState();
  14. }
  15. class _CarDataSectionState extends State<CarDataSection>
  16. with WidgetsBindingObserver {
  17. @override
  18. void initState() {
  19. super.initState();
  20. WidgetsBinding.instance.addObserver(this);
  21. WidgetsBinding.instance.addPostFrameCallback((_) {
  22. if (!mounted) return;
  23. unawaited(
  24. context.read<ObdController>().refreshDevices(requestPermission: false),
  25. );
  26. });
  27. }
  28. @override
  29. void dispose() {
  30. WidgetsBinding.instance.removeObserver(this);
  31. super.dispose();
  32. }
  33. @override
  34. void didChangeAppLifecycleState(AppLifecycleState state) {
  35. if (state == AppLifecycleState.resumed) {
  36. unawaited(
  37. context.read<ObdController>().refreshDevices(requestPermission: false),
  38. );
  39. }
  40. }
  41. @override
  42. Widget build(BuildContext context) {
  43. final colors = context.colors;
  44. final theme = Theme.of(context);
  45. final obd = context.watch<ObdController>();
  46. final weather = context.watch<WeatherController>();
  47. final selected = obd.selectedAddress;
  48. final options = <BluetoothDeviceInfo>[...obd.devices];
  49. if (selected != null && !options.any((d) => d.address == selected)) {
  50. options.add(
  51. BluetoothDeviceInfo(name: 'Saved adapter', address: selected),
  52. );
  53. }
  54. final selectedInOptions = options.any((d) => d.address == selected);
  55. final dropdownValue = selectedInOptions ? selected : null;
  56. final statusColor = obd.connected
  57. ? colors.accent
  58. : obd.error != null
  59. ? colors.danger
  60. : colors.inkFaint;
  61. return Column(
  62. crossAxisAlignment: CrossAxisAlignment.start,
  63. children: [
  64. Row(
  65. children: [
  66. Container(
  67. width: 42,
  68. height: 42,
  69. decoration: BoxDecoration(
  70. color: colors.accentSoft,
  71. borderRadius: BorderRadius.circular(14),
  72. ),
  73. alignment: Alignment.center,
  74. child: AppIcon(
  75. 'assets/icons/car.svg',
  76. size: 20,
  77. color: colors.accent,
  78. ),
  79. ),
  80. const SizedBox(width: 14),
  81. Expanded(
  82. child: Column(
  83. crossAxisAlignment: CrossAxisAlignment.start,
  84. children: [
  85. Text('OBD-II adapter', style: theme.textTheme.titleMedium),
  86. const SizedBox(height: 2),
  87. Row(
  88. children: [
  89. Container(
  90. width: 7,
  91. height: 7,
  92. decoration: BoxDecoration(
  93. color: statusColor,
  94. shape: BoxShape.circle,
  95. ),
  96. ),
  97. const SizedBox(width: 7),
  98. Expanded(
  99. child: Text(
  100. obd.statusLabel,
  101. maxLines: 1,
  102. overflow: TextOverflow.ellipsis,
  103. style: theme.textTheme.bodySmall?.copyWith(
  104. color: colors.inkMuted,
  105. ),
  106. ),
  107. ),
  108. ],
  109. ),
  110. ],
  111. ),
  112. ),
  113. TextButton.icon(
  114. onPressed: obd.loadingDevices
  115. ? null
  116. : () async {
  117. final ok = await obd.refreshDevices(
  118. requestPermission: true,
  119. );
  120. if (!ok && context.mounted) {
  121. _message(
  122. context,
  123. obd.devicesError ??
  124. 'Could not read paired Bluetooth devices.',
  125. );
  126. }
  127. },
  128. icon: AppIcon(
  129. 'assets/icons/search.svg',
  130. size: 16,
  131. color: colors.accent,
  132. ),
  133. label: Text(obd.needsBluetoothPermission ? 'Allow' : 'Find'),
  134. style: TextButton.styleFrom(foregroundColor: colors.accent),
  135. ),
  136. ],
  137. ),
  138. const SizedBox(height: 12),
  139. DropdownButtonFormField<String?>(
  140. key: ValueKey(dropdownValue),
  141. initialValue: dropdownValue,
  142. isExpanded: true,
  143. dropdownColor: colors.surface,
  144. style: theme.textTheme.bodyLarge?.copyWith(color: colors.ink),
  145. decoration: const InputDecoration(
  146. labelText: 'Bluetooth device',
  147. isDense: true,
  148. ),
  149. items: [
  150. const DropdownMenuItem<String?>(value: null, child: Text('None')),
  151. for (final device in options)
  152. DropdownMenuItem<String?>(
  153. value: device.address,
  154. child: Text(
  155. device.label,
  156. maxLines: 1,
  157. overflow: TextOverflow.ellipsis,
  158. ),
  159. ),
  160. ],
  161. onChanged: (value) async {
  162. await obd.selectAdapter(value);
  163. if (!context.mounted) return;
  164. if (obd.error != null) {
  165. _message(context, obd.error!);
  166. }
  167. },
  168. ),
  169. if (obd.devicesError != null && !obd.needsBluetoothPermission) ...[
  170. const SizedBox(height: 6),
  171. Text(
  172. obd.devicesError!,
  173. style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
  174. ),
  175. ],
  176. if (obd.bluetoothEnabled == false) ...[
  177. const SizedBox(height: 2),
  178. TextButton.icon(
  179. onPressed: () async {
  180. final opened = await obd.openBluetoothSettings();
  181. if (!opened && context.mounted) {
  182. _message(context, 'Could not open Bluetooth settings.');
  183. }
  184. },
  185. icon: AppIcon(
  186. 'assets/icons/settings.svg',
  187. size: 16,
  188. color: colors.accent,
  189. ),
  190. label: const Text('Open Bluetooth settings'),
  191. style: TextButton.styleFrom(
  192. foregroundColor: colors.accent,
  193. padding: EdgeInsets.zero,
  194. alignment: Alignment.centerLeft,
  195. ),
  196. ),
  197. ],
  198. const SizedBox(height: 8),
  199. Text(
  200. 'Pair the ELM327 in Android Bluetooth settings first. CarDash only '
  201. 'reads standard OBD-II PIDs; it never writes.',
  202. style: theme.textTheme.bodySmall?.copyWith(color: colors.inkMuted),
  203. ),
  204. Divider(color: colors.border, height: 30),
  205. Row(
  206. children: [
  207. Container(
  208. width: 38,
  209. height: 38,
  210. decoration: BoxDecoration(
  211. color: colors.surfaceStrong,
  212. borderRadius: BorderRadius.circular(13),
  213. border: Border.all(color: colors.border),
  214. ),
  215. alignment: Alignment.center,
  216. child: AppIcon(
  217. 'assets/icons/cloud.svg',
  218. size: 18,
  219. color: colors.inkMuted,
  220. ),
  221. ),
  222. const SizedBox(width: 14),
  223. Expanded(
  224. child: Column(
  225. crossAxisAlignment: CrossAxisAlignment.start,
  226. children: [
  227. Text(
  228. 'Location temperature',
  229. style: theme.textTheme.bodyLarge,
  230. ),
  231. const SizedBox(height: 2),
  232. Text(
  233. 'Show temperature from current location if no OBD-II connected.',
  234. style: theme.textTheme.bodySmall?.copyWith(
  235. color: colors.inkMuted,
  236. ),
  237. ),
  238. ],
  239. ),
  240. ),
  241. const SizedBox(width: 8),
  242. Switch(
  243. value: weather.enabled,
  244. onChanged: (value) async {
  245. final ok = await weather.setEnabled(value);
  246. if (!ok && context.mounted) {
  247. _message(
  248. context,
  249. weather.error ??
  250. 'Location permission is required for weather.',
  251. );
  252. }
  253. },
  254. thumbColor: WidgetStateProperty.resolveWith<Color?>(
  255. (states) => states.contains(WidgetState.selected)
  256. ? colors.accentInk
  257. : colors.inkFaint,
  258. ),
  259. trackColor: WidgetStateProperty.resolveWith<Color?>(
  260. (states) => states.contains(WidgetState.selected)
  261. ? colors.accent
  262. : colors.surfaceStrong,
  263. ),
  264. trackOutlineColor: WidgetStatePropertyAll(colors.border),
  265. ),
  266. ],
  267. ),
  268. if (weather.enabled && weather.error != null) ...[
  269. const SizedBox(height: 4),
  270. Text(
  271. weather.error!,
  272. style: theme.textTheme.bodySmall?.copyWith(color: colors.inkMuted),
  273. ),
  274. ],
  275. ],
  276. );
  277. }
  278. void _message(BuildContext context, String message) {
  279. final messenger = ScaffoldMessenger.of(context);
  280. messenger.hideCurrentSnackBar();
  281. messenger.showSnackBar(SnackBar(content: Text(message)));
  282. }
  283. }