bluetooth_thermometer 0.9.3
bluetooth_thermometer: ^0.9.3 copied to clipboard
A Flutter package for connecting to and reading temperature from ThermoWorks Bluetooth thermometers (Thermapen Blue, TempTest Blue).
example/lib/main.dart
// @dart=3.10
import 'package:flutter/material.dart';
import 'package:bluetooth_thermometer/bluetooth_thermometer.dart';
import 'info_screen.dart';
void main() => runApp(const MaterialApp(home: ThermometerExample()));
class ThermometerExample extends StatefulWidget {
final ThermometerClient? client;
const ThermometerExample({this.client, super.key});
@override
State<ThermometerExample> createState() => _ThermometerExampleState();
}
class _ThermometerExampleState extends State<ThermometerExample> {
late ThermometerClient _client;
List<ThermometerDevice> _devices = [];
bool _isScanning = false;
@override
void initState() {
super.initState();
_client = widget.client ?? ThermometerClient();
_client.devices.listen((list) => _safeSetState(() => _devices = list));
_client.errorStream.listen((error) {
if (!mounted) return;
error.isPermissionError
? context.showPermissionDialog()
: ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${error.message}'), backgroundColor: Colors.red),
);
});
}
Future<void> _toggleScan() async {
if (_isScanning) {
await _client.stopScan();
_safeSetState(() => _isScanning = false);
return;
}
_safeSetState(() {
_isScanning = true;
_devices = [];
});
try {
await _client.scanForDevices();
if (mounted && _devices.isEmpty) context.showNoDevicesDialog();
} catch (e) {
if (mounted && e.isPermissionError) context.showPermissionDialog();
} finally {
_safeSetState(() => _isScanning = false);
}
}
Future<void> _autoConnect() async {
if (_isScanning) return;
_safeSetState(() {
_isScanning = true;
_devices = [];
});
try {
final success = await _client.autoConnect();
if (!success && mounted) {
context.showInfoDialog(
title: 'Auto Connect Failed',
content: 'No known devices found nearby.\nTry scanning manually first.',
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Auto-connect error: $e'), backgroundColor: Colors.red),
);
}
} finally {
_safeSetState(() => _isScanning = false);
}
}
Future<void> _connectToDevice(ThermometerDevice device) async {
await _client.stopScan();
_safeSetState(() => _isScanning = false);
try {
_client.connect(device);
} catch (_) {}
}
@override
Widget build(BuildContext context) => ThermometerLifecycleManager(
client: _client,
onAutoConnectSuccess: () {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Auto-connected successfully!')));
}
},
onIdleDisconnect: () {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Disconnected due to inactivity')));
}
},
child: Scaffold(
appBar: AppBar(
title: const Text('Thermapen Blue Example'),
actions: [
ThermometerSelector(client: _client),
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () => Navigator.of(
context,
).push<void>(MaterialPageRoute<void>(builder: (_) => const InfoScreen())),
tooltip: 'Help & Permissions',
),
],
),
body: StreamBuilder<ThermometerConnectionState>(
stream: _client.connectionStateStream,
builder: (_, snapshot) {
final state = snapshot.data ?? .disconnected;
return switch (state) {
.connected => _ConnectedView(client: _client),
.connecting => const _ConnectingView(),
_ => _ScanView(
isScanning: _isScanning,
devices: _devices,
onToggleScan: _toggleScan,
onAutoConnect: _autoConnect,
onDeviceSelected: _connectToDevice,
client: _client, // Pass client
),
};
},
),
),
);
@override
void dispose() {
_client.dispose();
super.dispose();
}
void _safeSetState(VoidCallback fn) {
if (mounted) setState(fn);
}
}
class _ScanView extends StatelessWidget {
final bool isScanning;
final List<ThermometerDevice> devices;
final VoidCallback onToggleScan;
final VoidCallback onAutoConnect;
final ValueChanged<ThermometerDevice> onDeviceSelected;
final ThermometerClient? client; // Added client
const _ScanView({
required this.devices,
required this.isScanning,
required this.onAutoConnect,
required this.onDeviceSelected,
required this.onToggleScan,
this.client, // Added client
});
@override
Widget build(BuildContext context) => Column(
children: [
Padding(
padding: const .all(16),
child: Column(
children: [
Row(
mainAxisAlignment: .spaceEvenly,
children: [
ElevatedButton(
onPressed: onToggleScan,
child: Text(isScanning ? 'Scanning...' : 'Scan'),
),
OutlinedButton(
onPressed: isScanning ? null : onAutoConnect,
child: const Text('Auto Connect'),
),
],
),
if (client != null) ...[
const SizedBox(height: 16),
ThermometerSelector(
client: client!,
builder: (context, showModal) => TextButton.icon(
onPressed: showModal,
icon: const Icon(Icons.list),
label: const Text('Open Device List (Bottom Sheet)'),
),
),
],
],
),
),
Expanded(
child: devices.isEmpty
? Center(
child: Text(
isScanning
? 'Scanning for thermometers...'
: 'Tap "Scan" to find devices.\nSee (i) for help.',
textAlign: .center,
style: const TextStyle(color: Colors.grey),
),
)
: ListView.builder(
itemCount: devices.length,
itemBuilder: (_, i) => _DeviceListTile(devices[i], onTap: onDeviceSelected),
),
),
],
);
}
class _DeviceListTile extends StatelessWidget {
final ThermometerDevice device;
final ValueChanged<ThermometerDevice> onTap;
const _DeviceListTile(this.device, {required this.onTap});
@override
Widget build(BuildContext context) => ListTile(
title: Text(device.name),
subtitle: Text('ID: ${device.id}\nSignal: ${device.signalStrength.label}'),
trailing: const Icon(Icons.chevron_right),
onTap: () => onTap(device),
);
}
class _ConnectedView extends StatelessWidget {
final ThermometerClient client;
const _ConnectedView({required this.client});
@override
Widget build(BuildContext context) => Center(
child: Column(
mainAxisAlignment: .center,
children: [
const Text('Connected to Thermapen Blue', style: TextStyle(fontSize: 18)),
const SizedBox(height: 32),
ThermometerCaptureDisplay(client: client),
],
),
);
}
class _ConnectingView extends StatelessWidget {
const _ConnectingView();
@override
Widget build(BuildContext context) => const Center(
child: Column(
mainAxisAlignment: .center,
children: [CircularProgressIndicator(), SizedBox(height: 16), Text('Connecting...')],
),
);
}
extension on Object {
bool get isPermissionError {
final msg = toString().toLowerCase();
return msg.contains('permission') || msg.contains('unauthorized');
}
}
extension on BuildContext {
void showPermissionDialog() => _showInfoDialog(
title: 'Permissions Required',
content:
'Bluetooth permissions are missing or denied.\n\n'
'Please go to your system settings and manually grant Bluetooth '
'and Location permissions for this app.',
);
void showNoDevicesDialog() => _showInfoDialog(
title: 'No Devices Found',
content:
'No Thermapen Blue devices were found.\n\n'
'1. Ensure the device is powered ON (probe open).\n'
'2. Ensure it is within range.\n'
'3. Check permissions in the Info screen.',
);
void showInfoDialog({required String content, required String title}) =>
_showInfoDialog(title: title, content: content);
void _showInfoDialog({required String content, required String title}) => showDialog<void>(
context: this,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(content),
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('OK'))],
),
);
}