car_data_section.dart 8.6 KB

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