diff --git a/example/lib/adapter/ble_adapter.dart b/example/lib/adapter/ble_adapter.dart index 60c5a060..622a275b 100644 --- a/example/lib/adapter/ble_adapter.dart +++ b/example/lib/adapter/ble_adapter.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:blemulator_example/example_peripherals/generic_peripheral.dart'; import 'package:blemulator_example/model/ble_peripheral.dart'; import 'package:blemulator_example/example_peripherals/sensor_tag.dart'; +import 'package:blemulator_example/model/ble_service.dart'; import 'package:flutter_ble_lib/flutter_ble_lib.dart'; import 'package:blemulator/blemulator.dart'; @@ -30,6 +31,8 @@ class BleAdapter { Stream get blePeripherals => _blePeripheralsController.stream; + Map _scannedPeripherals = Map(); + factory BleAdapter(BleManager bleManager, Blemulator blemulator) { if (_instance == null) { _instance = BleAdapter._internal(bleManager, blemulator); @@ -58,14 +61,16 @@ class BleAdapter { Stream _startPeripheralScan() { return _bleManager.startPeripheralScan().map((scanResult) { + _scannedPeripherals.putIfAbsent( + scanResult.peripheral.identifier, () => scanResult.peripheral); return BlePeripheral( - scanResult.peripheral.name ?? - scanResult.advertisementData.localName ?? - 'Unknown', - scanResult.peripheral.identifier, - scanResult.rssi, - false, - BlePeripheralCategoryResolver.categoryForScanResult(scanResult), + scanResult.peripheral.name ?? + scanResult.advertisementData.localName ?? + 'Unknown', + scanResult.peripheral.identifier, + scanResult.rssi, + false, + BlePeripheralCategoryResolver.categoryForScanResult(scanResult), ); }); } @@ -82,4 +87,31 @@ class BleAdapter { _blemulator.addSimulatedPeripheral(GenericPeripheral()); _blemulator.simulate(); } + + Future> discoverAndGetServicesCharacteristics( + String peripheralId) async { + // TODO remove connect() call when connectivity handling is implemented + await _scannedPeripherals[peripheralId].connect(); + await _scannedPeripherals[peripheralId] + .discoverAllServicesAndCharacteristics(); + + List bleServices = []; + for (Service service + in await _scannedPeripherals[peripheralId].services()) { + List serviceCharacteristics = + await service.characteristics(); + List bleCharacteristics = serviceCharacteristics + .map( + (characteristic) => + BleCharacteristic.fromCharacteristic(characteristic), + ) + .toList(); + bleServices.add(BleService(service.uuid, bleCharacteristics)); + } + + // TODO remove when connectivity handling is implemented + _scannedPeripherals[peripheralId].disconnectOrCancelConnection(); + + return bleServices; + } } diff --git a/example/lib/common/components/property_row.dart b/example/lib/common/components/property_row.dart index 173008ed..f3deb818 100644 --- a/example/lib/common/components/property_row.dart +++ b/example/lib/common/components/property_row.dart @@ -7,6 +7,7 @@ class PropertyRow extends StatelessWidget { final Widget titleIcon; final Color titleColor; final String valueCompanion; + final TextStyle valueTextStyle; final Widget rowAccessory; final Widget titleAccessory; final Widget valueAccessory; @@ -17,6 +18,7 @@ class PropertyRow extends StatelessWidget { this.titleIcon, this.titleColor, @required this.value, + this.valueTextStyle = CustomTextStyle.cardValue, this.valueCompanion, this.rowAccessory, this.titleAccessory, @@ -113,7 +115,7 @@ class PropertyRow extends StatelessWidget { child: Text( value ?? '', textWidthBasis: TextWidthBasis.longestLine, - style: CustomTextStyle.cardValue, + style: valueTextStyle, ), ), ), @@ -129,7 +131,7 @@ class PropertyRow extends StatelessWidget { return Expanded( child: Text( value ?? '', - style: CustomTextStyle.cardValue, + style: valueTextStyle, ), ); } diff --git a/example/lib/device_details/device_detail_view.dart b/example/lib/device_details/device_detail_view.dart index 515e2a9c..abd8cf4e 100644 --- a/example/lib/device_details/device_detail_view.dart +++ b/example/lib/device_details/device_detail_view.dart @@ -71,7 +71,7 @@ class DeviceDetailsViewState extends State { child: Scaffold( backgroundColor: Colors.grey[300], appBar: AppBar( - title: Text('Devicie Details'), + title: Text('Device Details'), bottom: TabBar( tabs: [ Tab(icon: Icon(Icons.autorenew), text: "Automatic",), diff --git a/example/lib/example_peripherals/generic_peripheral.dart b/example/lib/example_peripherals/generic_peripheral.dart index a62ccaba..28d96d8c 100644 --- a/example/lib/example_peripherals/generic_peripheral.dart +++ b/example/lib/example_peripherals/generic_peripheral.dart @@ -14,15 +14,49 @@ class GenericPeripheral extends SimulatedPeripheral { advertisementInterval: Duration(milliseconds: milliseconds), services: [ SimulatedService( - uuid: 'F000AA00-0001-4000-B000-000000000000', - isAdvertised: true, - characteristics: [ - SimulatedCharacteristic( - uuid: 'F000AA10-0001-4000-B000-000000000000', - value: Uint8List.fromList([0]), - convenienceName: 'Generic characteristic'), - ], - convenienceName: 'Generic service'), + uuid: 'F000AA00-0001-4000-B000-000000000000', + isAdvertised: true, + characteristics: [ + SimulatedCharacteristic( + uuid: 'F000AA10-0001-4000-B000-000000000000', + value: Uint8List.fromList([0]), + convenienceName: 'Generic characteristic 1'), + ], + convenienceName: 'Generic service 1', + ), + SimulatedService( + uuid: 'F000AA01-0001-4000-B000-000000000000', + isAdvertised: true, + characteristics: [ + SimulatedCharacteristic( + uuid: 'F000AA11-0001-4000-B000-000000000000', + value: Uint8List.fromList([0]), + convenienceName: 'Generic characteristic 2'), + ], + convenienceName: 'Generic service 2', + ), + SimulatedService( + uuid: 'F000AA02-0001-4000-B000-000000000000', + isAdvertised: true, + characteristics: [ + SimulatedCharacteristic( + uuid: 'F000AA12-0001-4000-B000-000000000000', + value: Uint8List.fromList([0]), + convenienceName: 'Generic characteristic 3'), + ], + convenienceName: 'Generic service 3', + ), + SimulatedService( + uuid: 'F000AA03-0001-4000-B000-000000000000', + isAdvertised: true, + characteristics: [ + SimulatedCharacteristic( + uuid: 'F000AA13-0001-4000-B000-000000000000', + value: Uint8List.fromList([0]), + convenienceName: 'Generic characteristic 4'), + ], + convenienceName: 'Generic service 4', + ), ], ); diff --git a/example/lib/model/ble_service.dart b/example/lib/model/ble_service.dart new file mode 100644 index 00000000..14c66505 --- /dev/null +++ b/example/lib/model/ble_service.dart @@ -0,0 +1,54 @@ +import 'dart:typed_data'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter_ble_lib/flutter_ble_lib.dart'; + +class BleService extends Equatable { + final String uuid; + final List characteristics; + + BleService(this.uuid, this.characteristics); + + @override + List get props => [uuid, characteristics]; +} + +class BleCharacteristic extends Equatable { + final String uuid; + final Uint8List value; + final bool isReadable; + final bool isWritableWithResponse; + final bool isWritableWithoutResponse; + final bool isNotifiable; + final bool isIndicatable; + + BleCharacteristic( + this.uuid, + this.value, + this.isReadable, + this.isWritableWithResponse, + this.isWritableWithoutResponse, + this.isNotifiable, + this.isIndicatable, + ); + + @override + List get props => [ + uuid, + value, + isReadable, + isWritableWithResponse, + isWritableWithoutResponse, + isNotifiable, + isIndicatable + ]; + + BleCharacteristic.fromCharacteristic(Characteristic characteristic) + : uuid = characteristic.uuid, + value = null, + isReadable = characteristic.isReadable, + isWritableWithResponse = characteristic.isWritableWithResponse, + isWritableWithoutResponse = characteristic.isWritableWithoutResponse, + isNotifiable = characteristic.isNotifiable, + isIndicatable = characteristic.isIndicatable; +} diff --git a/example/lib/peripheral_details/components/characteristics_view.dart b/example/lib/peripheral_details/components/characteristics_view.dart new file mode 100644 index 00000000..91173faf --- /dev/null +++ b/example/lib/peripheral_details/components/characteristics_view.dart @@ -0,0 +1,50 @@ +import 'package:blemulator_example/model/ble_service.dart'; +import 'package:blemulator_example/styles/custom_text_style.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +class CharacteristicsView extends StatelessWidget { + final BleCharacteristic _characteristic; + + CharacteristicsView(this._characteristic); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "UUID: ${_characteristic.uuid}", + style: CustomTextStyle.characteristicsStyle, + ), + Text( + "Properties: ${_getCharacteristicProperties(_characteristic).toString()}", + style: CustomTextStyle.characteristicsStyle, + ), + ], + ), + ), + ); + } + + static List _getCharacteristicProperties( + BleCharacteristic characteristic) { + List properties = new List(); + + if (characteristic.isWritableWithResponse || + characteristic.isWritableWithoutResponse) { + properties.add("write"); + } + if (characteristic.isReadable) { + properties.add("read"); + } + if (characteristic.isIndicatable || characteristic.isNotifiable) { + properties.add("notify"); + } + + return properties; + } +} diff --git a/example/lib/peripheral_details/components/peripheral_details_view.dart b/example/lib/peripheral_details/components/peripheral_details_view.dart index c37863fb..9ffcc19d 100644 --- a/example/lib/peripheral_details/components/peripheral_details_view.dart +++ b/example/lib/peripheral_details/components/peripheral_details_view.dart @@ -1,6 +1,8 @@ -import 'package:blemulator_example/peripheral_details/bloc.dart'; import 'package:blemulator_example/common/components/property_row.dart'; +import 'package:blemulator_example/peripheral_details/bloc.dart'; +import 'package:blemulator_example/peripheral_details/components/services_sliver.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class PeripheralDetailsView extends StatelessWidget { @@ -8,24 +10,31 @@ class PeripheralDetailsView extends StatelessWidget { Widget build(BuildContext context) { return CustomScrollView( slivers: [ - SliverSafeArea( - top: false, - sliver: SliverPadding( - padding: const EdgeInsets.all(8.0), - sliver: SliverToBoxAdapter( - child: BlocBuilder( - builder: (context, state) { - return PropertyRow( - title: 'Identifier', - titleIcon: Icon(Icons.perm_device_information), - titleColor: Theme.of(context).primaryColor, - value: state.peripheral.id, - ); - }, - ), + SliverPadding( + padding: const EdgeInsets.all(8.0), + sliver: SliverToBoxAdapter( + child: BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + PropertyRow( + title: 'Identifier', + titleIcon: Icon(Icons.perm_device_information), + titleColor: Theme.of(context).primaryColor, + value: state.peripheral.id, + ), + ], + ); + }, ), ), ), + BlocBuilder( + builder: (context, state) { + return ServicesSliver(state.bleServiceStates); + }, + ) ], ); } diff --git a/example/lib/peripheral_details/components/services_sliver.dart b/example/lib/peripheral_details/components/services_sliver.dart new file mode 100644 index 00000000..b56e967d --- /dev/null +++ b/example/lib/peripheral_details/components/services_sliver.dart @@ -0,0 +1,65 @@ +import 'package:blemulator_example/common/components/property_row.dart'; +import 'package:blemulator_example/peripheral_details/components/characteristics_view.dart'; +import 'package:blemulator_example/peripheral_details/peripheral_details_bloc.dart'; +import 'package:blemulator_example/peripheral_details/peripheral_details_event.dart'; +import 'package:blemulator_example/peripheral_details/peripheral_details_state.dart'; +import 'package:blemulator_example/styles/custom_text_style.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ServicesSliver extends StatelessWidget { + final List _bleServiceStates; + + ServicesSliver(this._bleServiceStates); + + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => + _createServiceTileView(context, _bleServiceStates[index], index), + childCount: _bleServiceStates.length, + ), + ); + } + + Widget _createServiceTileView( + BuildContext context, + BleServiceState serviceState, + int index, + ) { + // ignore: close_sinks + final PeripheralDetailsBloc bloc = + BlocProvider.of(context); + + return Column( + children: [ + PropertyRow( + title: "Service UUID", + titleColor: Theme.of(context).primaryColor, + value: serviceState.service.uuid, + valueTextStyle: CustomTextStyle.serviceUuidStyle, + rowAccessory: IconButton( + icon: Icon( + serviceState.expanded ? Icons.unfold_less : Icons.unfold_more), + onPressed: () => bloc.add(ServiceViewExpandedEvent( + index, + )), + ), + ), + if (serviceState.expanded) + Padding( + padding: EdgeInsets.only(left: 16.0), + child: ListView.builder( + itemCount: serviceState.service.characteristics.length, + itemBuilder: (context, index) => CharacteristicsView( + serviceState.service.characteristics[index]), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + ), + ), + ], + ); + } +} diff --git a/example/lib/peripheral_details/peripheral_details_bloc.dart b/example/lib/peripheral_details/peripheral_details_bloc.dart index e7312d5f..364f57d9 100644 --- a/example/lib/peripheral_details/peripheral_details_bloc.dart +++ b/example/lib/peripheral_details/peripheral_details_bloc.dart @@ -3,13 +3,28 @@ import 'package:blemulator_example/adapter/ble_adapter.dart'; import 'package:blemulator_example/model/ble_peripheral.dart'; import 'package:bloc/bloc.dart'; import './bloc.dart'; +import 'package:flutter_ble_lib/flutter_ble_lib.dart'; class PeripheralDetailsBloc extends Bloc { BleAdapter _bleAdapter; final BlePeripheral _chosenPeripheral; - PeripheralDetailsBloc(this._bleAdapter, this._chosenPeripheral); + PeripheralDetailsBloc(this._bleAdapter, this._chosenPeripheral) { + try { + //TODO check if device is connected + _bleAdapter + .discoverAndGetServicesCharacteristics(_chosenPeripheral.id) + .then( + (bleServices) { + add(ServicesFetchedEvent(bleServices)); + }, + ); + } on BleError catch (e) { + // TODO handle the error. To my knowledge only possible cause is either peripheral got disconnected or Bluetooth has been turned off, + // so it should be handled the same way as disconnection. + } + } @override PeripheralDetailsState get initialState => @@ -18,5 +33,37 @@ class PeripheralDetailsBloc @override Stream mapEventToState( PeripheralDetailsEvent event, - ) async* {} + ) async* { + if (event is ServicesFetchedEvent) { + yield _mapServicesFetchedEventToState(event); + } else if (event is ServiceViewExpandedEvent) { + yield _mapServiceViewExpandedEventToState(event); + } + } + + PeripheralDetailsState _mapServicesFetchedEventToState( + ServicesFetchedEvent event, + ) { + return PeripheralDetailsState( + peripheral: state.peripheral, + bleServiceStates: event.services + .map((service) => BleServiceState(service: service, expanded: false)) + .toList(), + ); + } + + PeripheralDetailsState _mapServiceViewExpandedEventToState( + ServiceViewExpandedEvent event, + ) { + List newBleServiceStates = + List.from(state.bleServiceStates); + + newBleServiceStates[event.expandedViewIndex] = + BleServiceState(service: state.bleServiceStates[event.expandedViewIndex].service, expanded: !state.bleServiceStates[event.expandedViewIndex].expanded); + + return PeripheralDetailsState( + peripheral: state.peripheral, + bleServiceStates: newBleServiceStates, + ); + } } diff --git a/example/lib/peripheral_details/peripheral_details_event.dart b/example/lib/peripheral_details/peripheral_details_event.dart index 8f81d091..08f3e57c 100644 --- a/example/lib/peripheral_details/peripheral_details_event.dart +++ b/example/lib/peripheral_details/peripheral_details_event.dart @@ -1,5 +1,24 @@ +import 'package:blemulator_example/model/ble_service.dart'; import 'package:equatable/equatable.dart'; abstract class PeripheralDetailsEvent extends Equatable { const PeripheralDetailsEvent(); } + +class ServicesFetchedEvent extends PeripheralDetailsEvent { + @override + List get props => [services]; + + final List services; + + ServicesFetchedEvent(this.services); +} + +class ServiceViewExpandedEvent extends PeripheralDetailsEvent { + @override + List get props => [expandedViewIndex]; + + final int expandedViewIndex; + + ServiceViewExpandedEvent(this.expandedViewIndex); +} diff --git a/example/lib/peripheral_details/peripheral_details_state.dart b/example/lib/peripheral_details/peripheral_details_state.dart index 4248d224..63de5afa 100644 --- a/example/lib/peripheral_details/peripheral_details_state.dart +++ b/example/lib/peripheral_details/peripheral_details_state.dart @@ -1,12 +1,25 @@ import 'package:blemulator_example/model/ble_peripheral.dart'; +import 'package:blemulator_example/model/ble_service.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/cupertino.dart'; class PeripheralDetailsState extends Equatable { final BlePeripheral peripheral; + final List bleServiceStates; - const PeripheralDetailsState({@required this.peripheral}); + const PeripheralDetailsState( + {@required this.peripheral, this.bleServiceStates = const []}); @override - List get props => [peripheral]; + List get props => [peripheral, bleServiceStates]; } + +class BleServiceState extends Equatable { + final BleService service; + final bool expanded; + + @override + List get props => [service, expanded]; + + BleServiceState({@required this.service, @required this.expanded}); +} \ No newline at end of file diff --git a/example/lib/styles/custom_text_style.dart b/example/lib/styles/custom_text_style.dart index 0be0d72f..12f169ca 100644 --- a/example/lib/styles/custom_text_style.dart +++ b/example/lib/styles/custom_text_style.dart @@ -22,4 +22,16 @@ abstract class CustomTextStyle { fontWeight: FontWeight.w500, color: Colors.grey, ); + + static const serviceUuidStyle = TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w500, + color: Colors.grey, + ); + + static const characteristicsStyle = TextStyle( + fontSize: 12.0, + fontWeight: FontWeight.w500, + color: Colors.black26, + ); } diff --git a/example/test/mock/sample_ble_service.dart b/example/test/mock/sample_ble_service.dart new file mode 100644 index 00000000..4d65a8b3 --- /dev/null +++ b/example/test/mock/sample_ble_service.dart @@ -0,0 +1,37 @@ +import 'dart:typed_data'; + +import 'package:blemulator_example/model/ble_service.dart'; + +class SampleBleService extends BleService { + SampleBleService({ + String uuid = 'F000AA00-0001-4000-B000-000000000000', + List characteristics + }) : super(uuid, characteristics) { + if (characteristics == null) { + characteristics = [SampleBleCharacteristic()]; + } + } +} + +class SampleBleCharacteristic extends BleCharacteristic { + SampleBleCharacteristic({ + String uuid = 'F000AA00-0001-4000-B000-000000000000', + Uint8List value, + bool isReadable = true, + bool isWritableWithResponse = false, + bool isWritableWithoutResponse = false, + bool isNotifiable = false, + bool isIndicatable = false + }) : super( + uuid, + value, + isReadable, + isWritableWithResponse, + isWritableWithoutResponse, + isNotifiable, + isIndicatable) { + if (value == null) { + value = Uint8List(1); + } + } +} \ No newline at end of file diff --git a/example/test/peripheral_details/peripheral_details_bloc_test.dart b/example/test/peripheral_details/peripheral_details_bloc_test.dart index e12f8415..8642cec8 100644 --- a/example/test/peripheral_details/peripheral_details_bloc_test.dart +++ b/example/test/peripheral_details/peripheral_details_bloc_test.dart @@ -1,9 +1,14 @@ +import 'dart:math'; + import 'package:blemulator_example/model/ble_peripheral.dart'; +import 'package:blemulator_example/model/ble_service.dart'; import 'package:blemulator_example/peripheral_details/bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; import '../mock/mocks.dart'; import '../mock/sample_ble_peripheral.dart'; +import '../mock/sample_ble_service.dart'; void main() { PeripheralDetailsBloc peripheralDetailsBloc; @@ -13,9 +18,10 @@ void main() { setUp(() { bleAdapter = MockBleAdapter(); peripheral = SampleBlePeripheral(); + when(bleAdapter.discoverAndGetServicesCharacteristics(peripheral.id)) + .thenAnswer((_) => Future.value([])); - peripheralDetailsBloc = - PeripheralDetailsBloc(bleAdapter, peripheral); + peripheralDetailsBloc = PeripheralDetailsBloc(bleAdapter, peripheral); }); tearDown(() { @@ -26,4 +32,64 @@ void main() { test('initial state contains peripheral provided in the constructor', () { expect(peripheralDetailsBloc.initialState.peripheral, peripheral); }); + + test('should map ServicesFetchedEvent to PeripheralDetailsState', () async { + // given + List bleServices = [SampleBleService()]; + ServicesFetchedEvent event = ServicesFetchedEvent(bleServices); + List states = + bleServices.map((service) => BleServiceState(service: service, expanded: false)).toList(); + + PeripheralDetailsState expectedState = PeripheralDetailsState( + peripheral: peripheral, bleServiceStates: states); + + // when + peripheralDetailsBloc.add(event); + + // then + expectLater( + peripheralDetailsBloc, + emitsThrough(equals(expectedState)), + ); + }); + + test('should map ServiceViewExpandedEvent to PeripheralDetailsState when view expanded', () async { + // given + SampleBleService service = SampleBleService(); + BleServiceState newBleServiceState = BleServiceState(service: service, expanded: true); + + peripheralDetailsBloc.add(ServicesFetchedEvent([service])); + + PeripheralDetailsState expectedState = PeripheralDetailsState( + peripheral: peripheral, bleServiceStates: [newBleServiceState]); + + // when + peripheralDetailsBloc.add(ServiceViewExpandedEvent(0)); + + // then + await expectLater( + peripheralDetailsBloc, + emitsThrough(equals(expectedState)), + ); + }); + + test('should map ServiceViewExpandedEvent to PeripheralDetailsState when view collapsed', () async { + // given + SampleBleService service = SampleBleService(); + BleServiceState newBleServiceState = BleServiceState(service: service, expanded: false); + + peripheralDetailsBloc.add(ServicesFetchedEvent([service])); + + PeripheralDetailsState expectedState = PeripheralDetailsState( + peripheral: peripheral, bleServiceStates: [newBleServiceState]); + + // when + peripheralDetailsBloc.add(ServiceViewExpandedEvent(0)); + + // then + await expectLater( + peripheralDetailsBloc, + emitsThrough(equals(expectedState)), + ); + }); }