I have a device selection screen that lists relevant bluetooth devices and allows the user to select one.
DeviceSelectionScreen:
class DeviceSelectionScreen extends StatelessWidget {
const DeviceSelectionScreen({super.key});
@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
appBar: AppBar(
title: const Text("Devices"),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.pop(context);
}),
actions: const [
BluetoothScanningIndicator(),
],
),
body: const FitnessMachineList()));
}
}
I'm using a simple Cubit to wrap some streams being provided by the service that's doing the heavy lifting.
FitnessMachineList:
class FitnessMachineList extends StatelessWidget {
const FitnessMachineList({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => FitnessMachineDiscoveryCubit(),
child: BlocBuilder<FitnessMachineDiscoveryCubit, List<DeviceDescriptor>>(builder: (context, state) {
final cubit = context.read<FitnessMachineDiscoveryCubit>();
return ListView.builder(
itemCount: state.length,
itemBuilder: (context, index) {
final selectedDevice = state[index];
return ListTile(
title: Text(selectedDevice.name),
subtitle: Text(selectedDevice.address),
onTap: () {
cubit.selectDevice(state[index]);
Navigator.pop(context);
});
});
}));
}
}
FitnessMachineDiscoveryCubit:
class FitnessMachineDiscoveryCubit extends Cubit<List<DeviceDescriptor>> {
final FitnessMachineDiscoveryService _fitnessMachineDiscoveryService;
final FitnessMachineProvider _fitnessMachineProvider;
late StreamSubscription _discoSub;
FitnessMachineDiscoveryCubit()
: _fitnessMachineDiscoveryService = GetIt.I<FitnessMachineDiscoveryService>(),
_fitnessMachineProvider = GetIt.I<FitnessMachineProvider>(),
super([]) {
_discoSub = _fitnessMachineDiscoveryService.deviceStream.listen((devices) => onDevicesFound(devices));
_fitnessMachineDiscoveryService.start(); // This needs awaiting (I think)
}
void onDevicesFound(List<BluetoothDevice> devices) =>
emit(devices.map((e) => DeviceDescriptor(e, e.platformName, e.remoteId.str)).toList());
Future<void> selectDevice(DeviceDescriptor device) async {
await _fitnessMachineProvider.setMachine(device.device);
}
@override
Future<void> close() async {
_discoSub.cancel();
super.close();
}
}
This works a charm, but here's the rub, it only works the second time I open the DeviceSelectionScreen
with only the blank AppBar
showing the first time.
Using the debugger, I've been able to establish that the device stream isn't producing any events.
I strongly suspect that the issue is that the .start()
method on my FitnessMachineDiscoveryService
is async and I'm not awaiting it in my cubit. However, I'm not entirely sure how I can await a method when I create a cubit.
class FitnessMachineDiscoveryService {
Stream<List<BluetoothDevice>> get deviceStream => _deviceStreamController.stream;
final StreamController<List<BluetoothDevice>> _deviceStreamController;
FitnessMachineDiscoveryService() : _deviceStreamController = StreamController<List<BluetoothDevice>>.broadcast();
Future<void> start() async {
final scanningSub = FlutterBluePlus.onScanResults.listen((results) async {
if (results.isNotEmpty) {
parseResults(results);
}
});
FlutterBluePlus.cancelWhenScanComplete(scanningSub);
await FlutterBluePlus.startScan(withServices: [KnownServices.fitnessMachine], timeout: const Duration(seconds: 15));
}
void parseResults(List<ScanResult> results) {
_deviceStreamController.add(results.map((r) => r.device).toList());
}
}
I've tried this but create won't let you provide an async method (in any way I can find anyway):
return BlocProvider<FitnessMachineDiscoveryCubit>(create: (context) async {
final cubit = FitnessMachineDiscoveryCubit();
await cubit.start();
return cubit;
},
I also can't seem to await anything in the BlocBuilder
so can't start it there either.
Now, it's entirely possible that this is a red herring so if I'm barking up the wrong tree, this question is really about why my first page opening doesn't update but my second does.
Just on the offchance it's the cause, here's my GetIt setup:
extension DependencyInjectionExtensions on GetIt {
Future<void> addHardware() async {
WidgetsFlutterBinding.ensureInitialized();
if (await SafeDevice.isRealDevice) {
registerLazySingleton<TreadmillControlService>((() => TreadmillControlService()));
} else {
registerLazySingleton<TreadmillControlService>((() => FakeTreadmillControlService()));
print("Injecting fake treadmill service");
}
registerLazySingleton<FitnessMachineDiscoveryService>(() => FitnessMachineDiscoveryService());
registerLazySingleton<FitnessMachineProvider>(() => FitnessMachineProvider());
registerLazySingleton<FitnessMachineCommandDispatcher>((() => FitnessMachineCommandDispatcher()));
registerLazySingleton<FitnessMachineQueryDispatcher>((() => FitnessMachineQueryDispatcher()));
registerLazySingleton<DeviceSelectionScreen>(() => const DeviceSelectionScreen());
}
}
This was a classic XY issue.
There was nothing wrong with my Cubit or my streams. The issue lay with how FlutterBluePlus
initialises the Bluetooth device. So it didn't actually start scanning on the first call.
To fix this, I made another widget and wrapped this around my view which waits for the adapter to be ready before rendering the view.
class EnsureBluetoothEnabledWrapper extends StatelessWidget {
final Widget Function() onEnabled;
const EnsureBluetoothEnabledWrapper(this.onEnabled, {super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<BluetoothEnablementCubit>(
create: (ctx) => BluetoothEnablementCubit(BluetoothAdapterState.off),
child: BlocBuilder<BluetoothEnablementCubit, BluetoothAdapterState>(
builder: (ctx, state) {
if (state == BluetoothAdapterState.on) {
return onEnabled();
}
return const Text("Turn your bluetooth on");
},
),
);
}
}
class BluetoothEnablementCubit extends Cubit<BluetoothAdapterState> {
late StreamSubscription _sub;
BluetoothEnablementCubit(super.initialState) {
_sub = FlutterBluePlus.adapterState.listen((state) {
emit(state);
});
}
@override
Future<void> close() async {
_sub.cancel();
super.close();
}
}
class FitnessMachineList extends StatelessWidget {
const FitnessMachineList({super.key});
@override
Widget build(BuildContext context) {
return EnsureBluetoothEnabledWrapper(
() => BlocProvider<FitnessMachineDiscoveryCubit>(
create: (context) => FitnessMachineDiscoveryCubit(),
child: BlocBuilder<FitnessMachineDiscoveryCubit, List<DeviceDescriptor>>(builder: (context, state) {
final cubit = context.read<FitnessMachineDiscoveryCubit>();
return ListView.builder(
itemCount: state.length,
itemBuilder: (context, index) {
final selectedDevice = state[index];
return ListTile(
title: Text(selectedDevice.name),
subtitle: Text(selectedDevice.address),
onTap: () {
cubit.selectDevice(state[index]);
Navigator.pop(context);
});
});
})),
);
}
}
Note: This solution is incomplete as it doesn't handle if you disconnect later...