diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 7bb2df6..3c85cfe 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index a42444d..4f52071 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.2.1" apply false - id "org.jetbrains.kotlin.android" version "1.8.22" apply false + id "com.android.application" version "8.6.0" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false } include ":app" diff --git a/assets/images/shifter-wireframe.png b/assets/images/shifter-wireframe.png new file mode 100644 index 0000000..10b1034 Binary files /dev/null and b/assets/images/shifter-wireframe.png differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/flutter_rust_bridge.yaml b/flutter_rust_bridge.yaml new file mode 100644 index 0000000..e15ed91 --- /dev/null +++ b/flutter_rust_bridge.yaml @@ -0,0 +1,3 @@ +rust_input: crate::api +rust_root: rust/ +dart_output: lib/src/rust \ No newline at end of file diff --git a/integration_test/simple_test.dart b/integration_test/simple_test.dart new file mode 100644 index 0000000..b1a5075 --- /dev/null +++ b/integration_test/simple_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:abawo_bt_app/main.dart'; +import 'package:abawo_bt_app/src/rust/frb_generated.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async => await RustLib.init()); + testWidgets('Can call rust function', (WidgetTester tester) async { + await tester.pumpWidget(const MyApp()); + expect(find.textContaining('Result: `Hello, Tom!`'), findsOneWidget); + }); +} diff --git a/lib/controller/bluetooth.dart b/lib/controller/bluetooth.dart index e201f12..05bf056 100644 --- a/lib/controller/bluetooth.dart +++ b/lib/controller/bluetooth.dart @@ -1,131 +1,358 @@ import 'dart:async'; -import 'dart:io'; import 'package:anyhow/anyhow.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart' + hide ConnectionStatus, Result, Logger; import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'bluetooth.g.dart'; final log = Logger('BluetoothController'); -@riverpod +@Riverpod(keepAlive: true) +FlutterReactiveBle reactiveBle(Ref ref) { + ref.keepAlive(); + return FlutterReactiveBle(); +} + +@Riverpod(keepAlive: true) Future bluetooth(Ref ref) async { - final controller = BluetoothController(); - log.info(await controller.init()); + ref.keepAlive(); + final controller = BluetoothController(ref.read(reactiveBleProvider)); + await controller.init(); return controller; } +@Riverpod(keepAlive: true) +Stream<(ConnectionStatus, String?)> connectionStatus(Ref ref) { + final asyncController = ref.watch(bluetoothProvider); + return asyncController.when( + data: (controller) => controller.connectionStateStream, + loading: () => Stream.value((ConnectionStatus.disconnected, null)), + error: (_, __) => Stream.value((ConnectionStatus.disconnected, null)), + ); +} + +/// Represents the connection status of the Bluetooth device. +enum ConnectionStatus { disconnected, connecting, connected, disconnecting } + class BluetoothController { - StreamSubscription? _btStateSubscription; - StreamSubscription>? _scanResultsSubscription; - List _latestScanResults = []; + BluetoothController(this._ble); + + static const int defaultMtu = 64; + + final FlutterReactiveBle _ble; + + StreamSubscription? _bleStatusSubscription; + StreamSubscription? _scanResultsSubscription; + Timer? _scanTimeout; + final Map _scanResultsById = {}; + final _scanResultsSubject = + BehaviorSubject>.seeded(const []); + final _isScanningSubject = BehaviorSubject.seeded(false); + + String? _connectedDeviceId; + StreamSubscription? _connectionStateSubscription; + final _connectionStateSubject = + BehaviorSubject<(ConnectionStatus, String?)>.seeded( + (ConnectionStatus.disconnected, null)); + + Stream<(ConnectionStatus, String?)> get connectionStateStream => + _connectionStateSubject.stream; + + (ConnectionStatus, String?) get currentConnectionState => + _connectionStateSubject.value; + + Stream> get scanResultsStream => + _scanResultsSubject.stream; + + Stream get isScanningStream => _isScanningSubject.stream; + + List get scanResults => _scanResultsSubject.value; Future> init() async { - if (await FlutterBluePlus.isSupported == false) { - log.severe("Bluetooth is not supported on this device!"); - return bail("Bluetooth is not supported on this device!"); - } - - _btStateSubscription = - FlutterBluePlus.adapterState.listen((BluetoothAdapterState state) { - if (state == BluetoothAdapterState.on) { - log.info("Bluetooth is on!"); - // usually start scanning, connecting, etc - } else { - log.info("Bluetooth is off!"); - // show an error to the user, etc - } + _bleStatusSubscription ??= _ble.statusStream.listen((status) { + log.info('BLE status: $status'); }); - - if (!kIsWeb && Platform.isAndroid) { - await FlutterBluePlus.turnOn(); - } - return Ok(null); } - /// Start scanning for Bluetooth devices - /// - /// [withServices] - Optional list of service UUIDs to filter devices by - /// [withNames] - Optional list of device names to filter by - /// [timeout] - Optional duration after which scanning will automatically stop Future> startScan({ - List? withServices, - List? withNames, + List? withServices, Duration? timeout, + ScanMode scanMode = ScanMode.lowLatency, + bool requireLocationServicesEnabled = true, }) async { + if (_isScanningSubject.value) { + return Ok(null); + } + try { - // Wait for Bluetooth to be enabled - await FlutterBluePlus.adapterState - .where((val) => val == BluetoothAdapterState.on) - .first; + final status = _ble.status; + if (status != BleStatus.ready) { + await _ble.statusStream + .where((value) => value == BleStatus.ready) + .first; + } - // Set up scan results listener - _scanResultsSubscription = FlutterBluePlus.onScanResults.listen( - (results) { - if (results.isNotEmpty) { - _latestScanResults = results; - ScanResult latestResult = results.last; - log.info( - '${latestResult.device.remoteId}: "${latestResult.advertisementData.advName}" found!'); - } - }, - onError: (e) { - log.severe('Scan error: $e'); - }, - ); + _scanTimeout?.cancel(); + _scanResultsById.clear(); + _scanResultsSubject.add(const []); + _isScanningSubject.add(true); - // Clean up subscription when scanning completes - FlutterBluePlus.cancelWhenScanComplete(_scanResultsSubscription!); + _scanResultsSubscription = _ble + .scanForDevices( + withServices: withServices ?? const [], + scanMode: scanMode, + requireLocationServicesEnabled: requireLocationServicesEnabled, + ) + .listen((device) { + _scanResultsById[device.id] = device; + _scanResultsSubject + .add(_scanResultsById.values.toList(growable: false)); + }, onError: (Object error, StackTrace st) { + log.severe('Scan error: $error', error, st); + _isScanningSubject.add(false); + }); - // Start scanning with optional parameters - await FlutterBluePlus.startScan( - withServices: withServices ?? [], - withNames: withNames ?? [], - timeout: timeout, - ); + if (timeout != null) { + _scanTimeout = Timer(timeout, () { + unawaited(stopScan()); + }); + } return Ok(null); } catch (e) { + _isScanningSubject.add(false); return bail('Failed to start Bluetooth scan: $e'); } } - /// Stop an ongoing Bluetooth scan Future> stopScan() async { try { - await FlutterBluePlus.stopScan(); + _scanTimeout?.cancel(); + _scanTimeout = null; + await _scanResultsSubscription?.cancel(); + _scanResultsSubscription = null; + _isScanningSubject.add(false); return Ok(null); } catch (e) { + _isScanningSubject.add(false); return bail('Failed to stop Bluetooth scan: $e'); } } - /// Get the latest scan results - List get scanResults => _latestScanResults; - - /// Wait for the current scan to complete Future> waitForScanToComplete() async { try { - await FlutterBluePlus.isScanning.where((val) => val == false).first; + await isScanningStream.where((val) => val == false).first; return Ok(null); } catch (e) { return bail('Error waiting for scan to complete: $e'); } } - /// Check if currently scanning - Future get isScanning async { - return await FlutterBluePlus.isScanning.first; + Future get isScanning async => isScanningStream.first; + + Future> connect(DiscoveredDevice device, + {Duration? timeout}) async { + return connectById(device.id, timeout: timeout ?? Duration(seconds: 10)); + } + + Future> connectById( + String deviceId, { + Duration timeout = const Duration(seconds: 10), + Map>? servicesWithCharacteristicsToDiscover, + }) async { + final currentState = currentConnectionState; + final currentDeviceId = currentState.$2; + + if (deviceId == currentDeviceId && + (currentState.$1 == ConnectionStatus.connected || + currentState.$1 == ConnectionStatus.connecting)) { + log.info('Already connected or connecting to $deviceId.'); + if (currentState.$1 == ConnectionStatus.connected) { + unawaited(_requestMtuOnConnect(deviceId)); + } + return Ok(null); + } + + if (currentDeviceId != null && deviceId != currentDeviceId) { + final disconnectResult = await disconnect(); + if (disconnectResult.isErr()) { + return disconnectResult + .context('Failed to disconnect from previous device'); + } + await Future.delayed(const Duration(milliseconds: 300)); + } + + try { + await _connectionStateSubscription?.cancel(); + _updateConnectionState(ConnectionStatus.connecting, deviceId); + + _connectionStateSubscription = _ble + .connectToDevice( + id: deviceId, + connectionTimeout: timeout, + servicesWithCharacteristicsToDiscover: + servicesWithCharacteristicsToDiscover, + ) + .listen((update) { + switch (update.connectionState) { + case DeviceConnectionState.connected: + _connectedDeviceId = deviceId; + _updateConnectionState(ConnectionStatus.connected, deviceId); + unawaited(_requestMtuOnConnect(deviceId)); + break; + case DeviceConnectionState.connecting: + _updateConnectionState(ConnectionStatus.connecting, deviceId); + break; + case DeviceConnectionState.disconnecting: + _updateConnectionState(ConnectionStatus.disconnecting, deviceId); + break; + case DeviceConnectionState.disconnected: + _cleanUpConnection(); + break; + } + }, onError: (Object error, StackTrace st) { + log.severe('Failed to connect to $deviceId: $error', error, st); + _cleanUpConnection(); + }); + + return Ok(null); + } catch (e) { + _cleanUpConnection(); + return bail('Failed to connect to $deviceId: $e'); + } + } + + Future> disconnect() async { + final deviceIdToDisconnect = + _connectedDeviceId ?? _connectionStateSubject.value.$2; + if (deviceIdToDisconnect == null) { + _cleanUpConnection(); + return Ok(null); + } + + _updateConnectionState( + ConnectionStatus.disconnecting, deviceIdToDisconnect); + try { + await _connectionStateSubscription?.cancel(); + _connectionStateSubscription = null; + _cleanUpConnection(); + return Ok(null); + } catch (e) { + _cleanUpConnection(); + return bail('Failed to disconnect from $deviceIdToDisconnect: $e'); + } + } + + Future>> readCharacteristic( + String deviceId, + String serviceUuid, + String characteristicUuid, + ) async { + try { + final characteristic = QualifiedCharacteristic( + serviceId: Uuid.parse(serviceUuid), + characteristicId: Uuid.parse(characteristicUuid), + deviceId: deviceId, + ); + final value = await _ble.readCharacteristic(characteristic); + return Ok(value); + } catch (e) { + return bail('Error reading characteristic: $e'); + } + } + + Future> writeCharacteristic( + String deviceId, + String serviceUuid, + String characteristicUuid, + List value, { + bool withResponse = true, + }) async { + try { + final characteristic = QualifiedCharacteristic( + serviceId: Uuid.parse(serviceUuid), + characteristicId: Uuid.parse(characteristicUuid), + deviceId: deviceId, + ); + if (withResponse) { + await _ble.writeCharacteristicWithResponse( + characteristic, + value: value, + ); + } else { + await _ble.writeCharacteristicWithoutResponse( + characteristic, + value: value, + ); + } + return Ok(null); + } catch (e) { + return bail('Error writing characteristic: $e'); + } + } + + Future> requestMtu(String deviceId, + {int mtu = defaultMtu}) async { + try { + final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu); + log.info( + 'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu'); + return Ok(null); + } catch (e) { + return bail('Error requesting MTU $mtu for $deviceId: $e'); + } + } + + Future _requestMtuOnConnect(String deviceId) async { + final mtuResult = await requestMtu(deviceId, mtu: defaultMtu); + if (mtuResult.isErr()) { + log.warning( + 'MTU request after connect failed for $deviceId: ${mtuResult.unwrapErr()}'); + } + } + + Stream> subscribeToCharacteristic( + String deviceId, + String serviceUuid, + String characteristicUuid, + ) { + final characteristic = QualifiedCharacteristic( + serviceId: Uuid.parse(serviceUuid), + characteristicId: Uuid.parse(characteristicUuid), + deviceId: deviceId, + ); + return _ble.subscribeToCharacteristic(characteristic); + } + + void _updateConnectionState(ConnectionStatus status, String? deviceId) { + if (_connectionStateSubject.value.$1 == status && + _connectionStateSubject.value.$2 == deviceId) { + return; + } + _connectionStateSubject.add((status, deviceId)); + log.fine( + 'Connection state updated: $status, device: ${deviceId ?? 'none'}'); + } + + void _cleanUpConnection() { + _connectedDeviceId = null; + _updateConnectionState(ConnectionStatus.disconnected, null); } Future> dispose() async { + _scanTimeout?.cancel(); await _scanResultsSubscription?.cancel(); - await _btStateSubscription?.cancel(); + await _bleStatusSubscription?.cancel(); + await disconnect(); + await _scanResultsSubject.close(); + await _isScanningSubject.close(); + await _connectionStateSubject.close(); return Ok(null); } } diff --git a/lib/controller/bluetooth.g.dart b/lib/controller/bluetooth.g.dart index efa5738..319454a 100644 --- a/lib/controller/bluetooth.g.dart +++ b/lib/controller/bluetooth.g.dart @@ -6,7 +6,24 @@ part of 'bluetooth.dart'; // RiverpodGenerator // ************************************************************************** -String _$bluetoothHash() => r'5e9c37c57e723b84dd08fd8763e7c445b3a4dbf3'; +String _$bluetoothHash() => r'f1fb75c72a7a473fc545baea6bedfdf4a21ab26b'; + +String _$reactiveBleHash() => r'9c4d4f37f7a0da1741b42d6a4c3f0f00c2c07f3c'; + +/// See also [reactiveBle]. +@ProviderFor(reactiveBle) +final reactiveBleProvider = AutoDisposeProvider.internal( + reactiveBle, + name: r'reactiveBleProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$reactiveBleHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ReactiveBleRef = AutoDisposeProviderRef; /// See also [bluetooth]. @ProviderFor(bluetooth) @@ -23,5 +40,24 @@ final bluetoothProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef BluetoothRef = AutoDisposeFutureProviderRef; +String _$connectionStatusHash() => r'4dba587ef7703dabca4b2b9800e0798decfe2977'; + +/// See also [connectionStatus]. +@ProviderFor(connectionStatus) +final connectionStatusProvider = + AutoDisposeStreamProvider<(ConnectionStatus, String?)>.internal( + connectionStatus, + name: r'connectionStatusProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$connectionStatusHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ConnectionStatusRef + = AutoDisposeStreamProviderRef<(ConnectionStatus, String?)>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/controller/bluetooth.old.dart b/lib/controller/bluetooth.old.dart new file mode 100644 index 0000000..cca19cd --- /dev/null +++ b/lib/controller/bluetooth.old.dart @@ -0,0 +1,627 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:anyhow/anyhow.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'bluetooth.g.dart'; + +final log = Logger('BluetoothController'); + +@Riverpod(keepAlive: true) +Future bluetooth(Ref ref) async { + ref.keepAlive(); + final controller = BluetoothController(); + log.info(await controller.init()); + return controller; +} + +@Riverpod(keepAlive: true) +Stream<(ConnectionStatus, BluetoothDevice?)> connectionStatus(Ref ref) { + // Get the (potentially still loading) BluetoothController + final asyncController = ref.watch(bluetoothProvider); + + // If the controller is ready, return its stream. Otherwise, return an empty stream. + // The provider will automatically update when the controller becomes ready. + return asyncController.when( + data: (controller) => controller.connectionStateStream, + loading: () => Stream.value((ConnectionStatus.disconnected, null)), + error: (_, __) => Stream.value((ConnectionStatus.disconnected, null)), + ); +} + +/// Represents the connection status of the Bluetooth device. +enum ConnectionStatus { disconnected, connecting, connected, disconnecting } + +class BluetoothController { + StreamSubscription? _btStateSubscription; + StreamSubscription>? _scanResultsSubscription; + List _latestScanResults = []; + StreamSubscription? _servicesResetSubscription; + final Map> _servicesByDevice = {}; + final Map> + _characteristicsByDevice = {}; + // Connection State + BluetoothDevice? _connectedDevice; + StreamSubscription? _connectionStateSubscription; + final _connectionStateSubject = + BehaviorSubject<(ConnectionStatus, BluetoothDevice?)>.seeded( + (ConnectionStatus.disconnected, null)); + + /// Stream providing the current connection status and the connected device (if any). + Stream<(ConnectionStatus, BluetoothDevice?)> get connectionStateStream => + _connectionStateSubject.stream; + + /// Gets the latest connection status and device. + (ConnectionStatus, BluetoothDevice?) get currentConnectionState => + _connectionStateSubject.value; + + Future> init() async { + log.severe("CALLED FBPON!"); + if (await FlutterBluePlus.isSupported == false) { + log.severe("Bluetooth is not supported on this device!"); + return bail("Bluetooth is not supported on this device!"); + } + + _btStateSubscription = + FlutterBluePlus.adapterState.listen((BluetoothAdapterState state) { + if (state == BluetoothAdapterState.on) { + log.info("Bluetooth is on!"); + // usually start scanning, connecting, etc + } else { + log.info("Bluetooth is off!"); + // show an error to the user, etc + } + }); + + if (!kIsWeb && Platform.isAndroid) { + await FlutterBluePlus.turnOn(); + } + + connectionStateStream.listen((state) { + log.info('Connection state changed: $state'); + }); + + return Ok(null); + } + + /// Start scanning for Bluetooth devices + /// + /// [withServices] - Optional list of service UUIDs to filter devices by + /// [withNames] - Optional list of device names to filter by + /// [timeout] - Optional duration after which scanning will automatically stop + Future> startScan({ + List? withServices, + List? withNames, + Duration? timeout, + }) async { + try { + // Wait for Bluetooth to be enabled + await FlutterBluePlus.adapterState + .where((val) => val == BluetoothAdapterState.on) + .first; + + // Set up scan results listener + _scanResultsSubscription = FlutterBluePlus.onScanResults.listen( + (results) { + if (results.isNotEmpty) { + _latestScanResults = results; + ScanResult latestResult = results.last; + log.info( + '${latestResult.device.remoteId}: "${latestResult.advertisementData.advName}" found!'); + } + }, + onError: (e) { + log.severe('Scan error: $e'); + }, + ); + + // Clean up subscription when scanning completes + FlutterBluePlus.cancelWhenScanComplete(_scanResultsSubscription!); + + // Start scanning with optional parameters + await FlutterBluePlus.startScan( + withServices: withServices ?? [], + withNames: withNames ?? [], + timeout: timeout, + ); + + return Ok(null); + } catch (e) { + return bail('Failed to start Bluetooth scan: $e'); + } + } + + /// Stop an ongoing Bluetooth scan + Future> stopScan() async { + try { + await FlutterBluePlus.stopScan(); + return Ok(null); + } catch (e) { + return bail('Failed to stop Bluetooth scan: $e'); + } + } + + /// Get the latest scan results + List get scanResults => _latestScanResults; + + /// Wait for the current scan to complete + Future> waitForScanToComplete() async { + try { + await FlutterBluePlus.isScanning.where((val) => val == false).first; + return Ok(null); + } catch (e) { + return bail('Error waiting for scan to complete: $e'); + } + } + + /// Check if currently scanning + Future get isScanning async { + return await FlutterBluePlus.isScanning.first; + } + + /// Connects to a specific Bluetooth device. + /// + /// Ensures that only one device is connected at a time. If another device + /// is already connected or connecting, it will be disconnected first. + Future> connect(BluetoothDevice device, + {Duration? timeout}) async { + final currentState = currentConnectionState; + final currentDevice = currentState.$2; + + // Prevent connecting if already connected/connecting to the *same* device + if (device.remoteId == currentDevice?.remoteId && + (currentState.$1 == ConnectionStatus.connected || + currentState.$1 == ConnectionStatus.connecting)) { + log.info('Currently connected device: ${currentState.$2}'); + log.info('Already connected or connecting to ${device.remoteId}.'); + return Ok(null); // Or potentially an error/different status? + } + + log.info('Attempting to connect to ${device.remoteId}...'); + + // If connecting or connected to a *different* device, disconnect it first. + if (currentDevice != null && device.remoteId != currentDevice.remoteId) { + log.info( + 'Disconnecting from previous device ${currentDevice.remoteId} first.'); + final disconnectResult = await disconnect(); + if (disconnectResult.isErr()) { + return disconnectResult + .context('Failed to disconnect from previous device'); + } + // Wait a moment for the disconnection to fully process + await Future.delayed(const Duration(milliseconds: 500)); + } + + try { + // Cancel any previous connection state listener before starting a new one + await _connectionStateSubscription?.cancel(); + _connectionStateSubscription = + device.connectionState.listen((BluetoothConnectionState state) async { + log.info('[${device.remoteId}] Connection state changed: $state'); + switch (state) { + case BluetoothConnectionState.connected: + _connectedDevice = device; + _updateConnectionState(ConnectionStatus.connected, device); + // IMPORTANT: Discover services after connecting + try { + _attachServicesResetListener(device); + final servicesResult = + await _discoverAndCacheServices(device, force: true); + if (servicesResult.isErr()) { + throw servicesResult.unwrapErr(); + } + log.info( + '[${device.remoteId}] Services discovered: \n${servicesResult.unwrap().map((e) => e.uuid.toString()).join('\n')}'); + } catch (e) { + log.severe( + '[${device.remoteId}] Error discovering services: $e. Disconnecting.'); + // Disconnect if service discovery fails, as the connection might be unusable + await disconnect(); + } + break; + case BluetoothConnectionState.disconnected: + if (_connectionStateSubject.value.$1 != + ConnectionStatus.connected) { + log.warning( + '[${device.remoteId}] Disconnected WITHOUT being connected! Reason: ${device.disconnectReason?.code} ${device.disconnectReason?.description}\nDoing nothing'); + break; + } else { + log.warning( + '[${device.remoteId}] Disconnected. Reason: ${device.disconnectReason?.code} ${device.disconnectReason?.description}'); + // Only clean up if this is the device we were connected/connecting to + if (_connectionStateSubject.value.$2?.remoteId == + device.remoteId) { + // Clean up connection state, handling disconnection. + // In general, reconnection is better, but this is how it's handled here. + // App behavior would be to go back to the homepage on disconnection + _cleanUpConnection(); + } else { + log.info( + '[${device.remoteId}] Received disconnect for a device we were not tracking.'); + } + break; + } + case BluetoothConnectionState.connecting: + case BluetoothConnectionState.disconnecting: + // deprecated states + log.warning( + 'Received unexpected connection state: ${device.connectionState}. This should not happen.'); + break; + } + }); + + await device.connect( + license: License.free, + timeout: timeout ?? const Duration(seconds: 15), + mtu: 512, + ); + + // Note: Success is primarily handled by the connectionState listener + log.info( + 'Connection initiated for ${device.remoteId}. Waiting for state change.'); + _connectionStateSubject.add((ConnectionStatus.connected, device)); + return Ok(null); + } catch (e) { + log.severe('Failed to connect to ${device.remoteId}: $e'); + _cleanUpConnection(); // Clean up state on connection failure + return bail('Failed to connect to ${device.remoteId}: $e'); + } + } + + /// Connects to a device using its remote ID string with a specific timeout. + Future> connectById(String remoteId, + {Duration timeout = const Duration(seconds: 10)}) async { + log.info('Attempting to connect by ID: $remoteId with timeout: $timeout'); + try { + // Get the BluetoothDevice object from the ID + final device = BluetoothDevice.fromId(remoteId); + + // Call the existing connect method, passing the device and timeout + // Assumes the 'connect' method below is modified to accept the timeout. + return await connect(device, timeout: timeout); // Pass timeout here + } catch (e, st) { + // Catch potential errors from fromId or during connection setup before connect() is called + log.severe('Error connecting by ID $remoteId: $e'); + _cleanUpConnection(); // Ensure state is cleaned up + return bail('Failed to initiate connection for ID $remoteId: $e', st); + } + } + + /// Disconnects from the currently connected device. + Future> disconnect() async { + final deviceToDisconnect = + _connectedDevice ?? _connectionStateSubject.value.$2; + if (deviceToDisconnect == null) { + log.info('No device is currently connected or connecting.'); + // Ensure state is definitely disconnected if called unnecessarily + _cleanUpConnection(); + return Ok(null); + } + + log.info('Disconnecting from ${deviceToDisconnect.remoteId}...'); + _updateConnectionState(ConnectionStatus.disconnecting, deviceToDisconnect); + + try { + await deviceToDisconnect.disconnect(); + log.info('Disconnect command sent to ${deviceToDisconnect.remoteId}.'); + // State update to disconnected is handled by the connectionState listener + // but we call cleanup here as a safety measure in case the listener fails + _cleanUpConnection(); + return Ok(null); + } catch (e) { + log.severe( + 'Failed to disconnect from ${deviceToDisconnect.remoteId}: $e'); + // Even on error, try to clean up the state + _cleanUpConnection(); + return bail( + 'Failed to disconnect from ${deviceToDisconnect.remoteId}: $e'); + } + } + + void _updateConnectionState( + ConnectionStatus status, BluetoothDevice? device) { + // Avoid emitting redundant states + if (_connectionStateSubject.value.$1 == status && + _connectionStateSubject.value.$2?.remoteId == device?.remoteId) { + return; + } + _connectionStateSubject.add((status, device)); + log.fine( + 'Connection state updated: $status, Device: ${device?.remoteId ?? 'none'}'); + } + + Future>> discoverServices( + BluetoothDevice device, { + bool force = false, + }) async { + return _discoverAndCacheServices(device, force: force); + } + + Future> writeCharacteristic( + BluetoothDevice device, + String serviceUuid, + String characteristicUuid, + List value, { + bool withoutResponse = false, + bool allowLongWrite = false, + int timeout = 15, + }) async { + final serviceGuid = Guid(serviceUuid); + final characteristicGuid = Guid(characteristicUuid); + final chrResult = + await _getCharacteristic(device, serviceGuid, characteristicGuid); + if (chrResult.isErr()) { + return chrResult.context('Failed to resolve characteristic for write'); + } + + try { + await chrResult.unwrap().write( + value, + withoutResponse: withoutResponse, + allowLongWrite: allowLongWrite, + timeout: timeout, + ); + return Ok(null); + } catch (e) { + return bail('Error writing characteristic $characteristicUuid: $e'); + } + } + + Future>>> subscribeToNotifications( + BluetoothDevice device, + String serviceUuid, + String characteristicUuid, { + void Function(List)? onValue, + bool useLastValueStream = false, + int timeout = 15, + }) async { + return _subscribeToCharacteristic( + device, + serviceUuid, + characteristicUuid, + useLastValueStream: useLastValueStream, + timeout: timeout, + forceIndications: false, + onValue: onValue, + ); + } + + Future>>> subscribeToIndications( + BluetoothDevice device, + String serviceUuid, + String characteristicUuid, { + void Function(List)? onValue, + bool useLastValueStream = false, + int timeout = 15, + }) async { + return _subscribeToCharacteristic( + device, + serviceUuid, + characteristicUuid, + useLastValueStream: useLastValueStream, + timeout: timeout, + forceIndications: true, + onValue: onValue, + ); + } + + Future> unsubscribeFromCharacteristic( + BluetoothDevice device, + String serviceUuid, + String characteristicUuid, { + int timeout = 15, + }) async { + final serviceGuid = Guid(serviceUuid); + final characteristicGuid = Guid(characteristicUuid); + final chrResult = + await _getCharacteristic(device, serviceGuid, characteristicGuid); + if (chrResult.isErr()) { + return chrResult + .context('Failed to resolve characteristic to unsubscribe'); + } + + try { + await chrResult.unwrap().setNotifyValue(false, timeout: timeout); + return Ok(null); + } catch (e) { + return bail('Error disabling notifications for $characteristicUuid: $e'); + } + } + + /// Helper function to clean up connection resources and state. + Future _cleanUpConnection() async { + log.fine('Cleaning up connection state and subscriptions.'); + _connectedDevice = null; + await _servicesResetSubscription?.cancel(); + _servicesResetSubscription = null; + _servicesByDevice.clear(); + _characteristicsByDevice.clear(); + await _connectionStateSubscription?.cancel(); + _connectionStateSubscription = null; + _updateConnectionState(ConnectionStatus.disconnected, null); + } + + Future> dispose() async { + await _scanResultsSubscription?.cancel(); + await _btStateSubscription?.cancel(); + await disconnect(); // Ensure disconnection on dispose + await _connectionStateSubject.close(); + return Ok(null); + } + + Future>> readCharacteristic( + BluetoothDevice device, String svcUuid, String characteristic) async { + // Implement reading characteristic logic here + // This is a placeholder implementation + log.info( + 'Reading characteristic from device: $device, characteristic: $characteristic'); + final serviceUUID = Guid(svcUuid); + final characteristicUUID = Guid(characteristic); + + if (!device.servicesList.map((e) => e.uuid).contains(serviceUUID)) { + return bail('Service $svcUuid not found on device ${device.remoteId}'); + } + + final BluetoothService service = + (device.servicesList).firstWhere((s) => s.uuid == serviceUUID); + + if (service.characteristics.isEmpty || + !service.characteristics + .map((c) => c.uuid) + .contains(characteristicUUID)) { + return bail( + 'Characteristic $characteristic not found on device ${device.remoteId}'); + } + + try { + final val = await service.characteristics + .firstWhere((c) => c.uuid == characteristicUUID) + .read(); + return Ok(val); + } catch (e) { + return bail('Error reading characteristic: $e'); + } + } + + String _deviceKey(BluetoothDevice device) => device.remoteId.str; + + String _characteristicKey(Guid serviceUuid, Guid characteristicUuid) => + '${serviceUuid.toString()}|${characteristicUuid.toString()}'; + + void _cacheServices(BluetoothDevice device, List services) { + final serviceMap = {}; + final characteristicMap = {}; + + for (final service in services) { + serviceMap[service.uuid] = service; + for (final chr in service.characteristics) { + characteristicMap[_characteristicKey(service.uuid, chr.uuid)] = chr; + } + } + + _servicesByDevice[_deviceKey(device)] = serviceMap; + _characteristicsByDevice[_deviceKey(device)] = characteristicMap; + } + + void _attachServicesResetListener(BluetoothDevice device) { + _servicesResetSubscription?.cancel(); + _servicesResetSubscription = device.onServicesReset.listen((_) async { + log.info('[${device.remoteId}] Services reset. Re-discovering.'); + final res = await _discoverAndCacheServices(device, force: true); + if (res.isErr()) { + log.severe( + '[${device.remoteId}] Failed to re-discover services: ${res.unwrapErr()}'); + } + }); + device.cancelWhenDisconnected(_servicesResetSubscription!); + } + + Future>> _discoverAndCacheServices( + BluetoothDevice device, { + bool force = false, + }) async { + try { + if (!force) { + final cached = _servicesByDevice[_deviceKey(device)]; + if (cached != null && cached.isNotEmpty) { + return Ok(cached.values.toList()); + } + } + if (!force && device.servicesList.isNotEmpty) { + _cacheServices(device, device.servicesList); + return Ok(device.servicesList); + } + + final services = await device.discoverServices(); + _cacheServices(device, services); + return Ok(services); + } catch (e) { + return bail('Failed to discover services for ${device.remoteId}: $e'); + } + } + + Future> _getCharacteristic( + BluetoothDevice device, + Guid serviceUuid, + Guid characteristicUuid, + ) async { + final deviceKey = _deviceKey(device); + final cached = _characteristicsByDevice[deviceKey] + ?[_characteristicKey(serviceUuid, characteristicUuid)]; + if (cached != null) { + return Ok(cached); + } + + final discoverResult = await _discoverAndCacheServices(device); + if (discoverResult.isErr()) { + return bail(discoverResult.unwrapErr().toString()); + } + + final refreshed = _characteristicsByDevice[deviceKey] + ?[_characteristicKey(serviceUuid, characteristicUuid)]; + if (refreshed == null) { + return bail( + 'Characteristic $characteristicUuid not found on service $serviceUuid for device ${device.remoteId}'); + } + + return Ok(refreshed); + } + + Future>>> _subscribeToCharacteristic( + BluetoothDevice device, + String serviceUuid, + String characteristicUuid, { + required bool forceIndications, + required bool useLastValueStream, + required int timeout, + void Function(List)? onValue, + }) async { + final serviceGuid = Guid(serviceUuid); + final characteristicGuid = Guid(characteristicUuid); + final chrResult = + await _getCharacteristic(device, serviceGuid, characteristicGuid); + if (chrResult.isErr()) { + return bail('Failed to resolve characteristic subscription: ' + '${chrResult.unwrapErr()}'); + } + + final characteristic = chrResult.unwrap(); + final properties = characteristic.properties; + if (forceIndications && !properties.indicate) { + return bail( + 'Characteristic $characteristicUuid does not support indications'); + } + if (!forceIndications && !properties.notify && !properties.indicate) { + return bail( + 'Characteristic $characteristicUuid does not support notifications'); + } + if (forceIndications && !kIsWeb && !Platform.isAndroid) { + return bail('Indications can only be forced on Android.'); + } + + try { + final stream = useLastValueStream + ? characteristic.lastValueStream + : characteristic.onValueReceived; + final subscription = stream.listen(onValue ?? (_) {}); + device.cancelWhenDisconnected(subscription); + + await characteristic.setNotifyValue( + true, + timeout: timeout, + forceIndications: forceIndications, + ); + + return Ok(subscription); + } catch (e) { + return bail( + 'Error subscribing to characteristic $characteristicUuid: $e'); + } + } +} diff --git a/lib/controller/connected_device.dart b/lib/controller/connected_device.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/controller/connected_device.dart @@ -0,0 +1 @@ + diff --git a/lib/database/database.dart b/lib/database/database.dart new file mode 100644 index 0000000..c330622 --- /dev/null +++ b/lib/database/database.dart @@ -0,0 +1,143 @@ +import 'package:anyhow/anyhow.dart'; +import 'package:drift/drift.dart'; +import 'package:drift_flutter/drift_flutter.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'database.g.dart'; + +@riverpod +class NConnectedDevices extends _$NConnectedDevices { + @override + Future> build() async { + final db = await ref.watch(databaseProvider); + return await db.getAllConnectedDevices(); + } + + Future> addConnectedDevice( + ConnectedDevicesCompanion device) async { + final db = await ref.watch(databaseProvider); + final res = await db.addConnectedDevice(device); + if (res.isOk()) { + ref.invalidateSelf(); + } + return res; + } + + Future> deleteConnectedDevice(int id) async { + final db = await ref.watch(databaseProvider); + final res = await db.deleteConnectedDevice(id); + if (res.isOk()) { + ref.invalidateSelf(); + } + return res; + } +} + +/// Provider for the [AppDatabase] instance +final databaseProvider = Provider((ref) { + final database = AppDatabase(); + ref.onDispose(() => database.close()); + return database; +}); + +/// Provider for all connected devices as a stream +final connectedDevicesStreamProvider = + StreamProvider>((ref) { + final database = ref.watch(databaseProvider); + return database.getAllConnectedDevicesStream(); +}); + +/// Provider for all connected devices as a future +final connectedDevicesProvider = FutureProvider>((ref) { + final database = ref.watch(databaseProvider); + return database.getAllConnectedDevices(); +}); + +class ConnectedDevices extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get deviceName => text()(); + TextColumn get deviceAddress => text()(); + TextColumn get deviceType => text()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + DateTimeColumn get lastConnectedAt => dateTime().nullable()(); +} + +@DriftDatabase(tables: [ConnectedDevices]) +class AppDatabase extends _$AppDatabase { + // After generating code, this class needs to define a `schemaVersion` getter + // and a constructor telling drift where the database should be stored. + // These are described in the getting started guide: https://drift.simonbinder.eu/setup/ + AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); + + @override + int get schemaVersion => 1; + + static QueryExecutor _openConnection() { + return driftDatabase( + name: 'my_database', + native: const DriftNativeOptions( + // By default, `driftDatabase` from `package:drift_flutter` stores the + // database files in `getApplicationDocumentsDirectory()`. + databaseDirectory: getApplicationSupportDirectory, + ), + // If you need web support, see https://drift.simonbinder.eu/platforms/web/ + ); + } + + Future> getAllConnectedDevices() { + return select(connectedDevices).get(); + } + + /// Adds a new connected device to the database. + /// + /// [device] is a [ConnectedDevicesCompanion] representing the device to be inserted. + /// + /// Returns a [Result] indicating success or failure of the device insertion. + /// On successful insertion, returns [Ok]\(rowid\). On failure, returns an error with a descriptive message. + Future> addConnectedDevice( + ConnectedDevicesCompanion device) async { + try { + if (!device.deviceAddress.present) { + return bail('Device address is required to save a connected device.'); + } + + final exists = await (select(connectedDevices) + ..where( + (tbl) => tbl.deviceAddress.equals(device.deviceAddress.value))) + .getSingleOrNull(); + if (exists != null) { + return bail('Device ${device.deviceAddress.value} is already added.'); + } + + final rowid = await into(connectedDevices).insert(device); + return Ok(rowid); + } catch (e, st) { + return bail('Failed to add device: $e', st); + } + } + + /// Deletes a connected device from the database by its ID. + /// + /// [id] is the ID of the device to be deleted. + /// + /// Returns a [Result] indicating success or failure of the deletion. + /// On successful deletion, returns [Ok](number of deleted rows). On failure, returns an error with a descriptive message. + Future> deleteConnectedDevice(int id) async { + try { + final count = await (delete(connectedDevices) + ..where((tbl) => tbl.id.equals(id))) + .go(); + if (count == 0) { + return bail('Device with id $id not found.'); + } + return Ok(()); + } catch (e, st) { + return bail('Failed to delete device with id $id: $e', st); + } + } + + Stream> getAllConnectedDevicesStream() { + return select(connectedDevices).watch(); + } +} diff --git a/lib/database/database.g.dart b/lib/database/database.g.dart new file mode 100644 index 0000000..5c1a273 --- /dev/null +++ b/lib/database/database.g.dart @@ -0,0 +1,586 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class $ConnectedDevicesTable extends ConnectedDevices + with TableInfo<$ConnectedDevicesTable, ConnectedDevice> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ConnectedDevicesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _deviceNameMeta = + const VerificationMeta('deviceName'); + @override + late final GeneratedColumn deviceName = GeneratedColumn( + 'device_name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _deviceAddressMeta = + const VerificationMeta('deviceAddress'); + @override + late final GeneratedColumn deviceAddress = GeneratedColumn( + 'device_address', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _deviceTypeMeta = + const VerificationMeta('deviceType'); + @override + late final GeneratedColumn deviceType = GeneratedColumn( + 'device_type', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime); + static const VerificationMeta _lastConnectedAtMeta = + const VerificationMeta('lastConnectedAt'); + @override + late final GeneratedColumn lastConnectedAt = + GeneratedColumn('last_connected_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + @override + List get $columns => + [id, deviceName, deviceAddress, deviceType, createdAt, lastConnectedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'connected_devices'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('device_name')) { + context.handle( + _deviceNameMeta, + deviceName.isAcceptableOrUnknown( + data['device_name']!, _deviceNameMeta)); + } else if (isInserting) { + context.missing(_deviceNameMeta); + } + if (data.containsKey('device_address')) { + context.handle( + _deviceAddressMeta, + deviceAddress.isAcceptableOrUnknown( + data['device_address']!, _deviceAddressMeta)); + } else if (isInserting) { + context.missing(_deviceAddressMeta); + } + if (data.containsKey('device_type')) { + context.handle( + _deviceTypeMeta, + deviceType.isAcceptableOrUnknown( + data['device_type']!, _deviceTypeMeta)); + } else if (isInserting) { + context.missing(_deviceTypeMeta); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + if (data.containsKey('last_connected_at')) { + context.handle( + _lastConnectedAtMeta, + lastConnectedAt.isAcceptableOrUnknown( + data['last_connected_at']!, _lastConnectedAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ConnectedDevice map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ConnectedDevice( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + deviceName: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}device_name'])!, + deviceAddress: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}device_address'])!, + deviceType: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}device_type'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + lastConnectedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}last_connected_at']), + ); + } + + @override + $ConnectedDevicesTable createAlias(String alias) { + return $ConnectedDevicesTable(attachedDatabase, alias); + } +} + +class ConnectedDevice extends DataClass implements Insertable { + final int id; + final String deviceName; + final String deviceAddress; + final String deviceType; + final DateTime createdAt; + final DateTime? lastConnectedAt; + const ConnectedDevice( + {required this.id, + required this.deviceName, + required this.deviceAddress, + required this.deviceType, + required this.createdAt, + this.lastConnectedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['device_name'] = Variable(deviceName); + map['device_address'] = Variable(deviceAddress); + map['device_type'] = Variable(deviceType); + map['created_at'] = Variable(createdAt); + if (!nullToAbsent || lastConnectedAt != null) { + map['last_connected_at'] = Variable(lastConnectedAt); + } + return map; + } + + ConnectedDevicesCompanion toCompanion(bool nullToAbsent) { + return ConnectedDevicesCompanion( + id: Value(id), + deviceName: Value(deviceName), + deviceAddress: Value(deviceAddress), + deviceType: Value(deviceType), + createdAt: Value(createdAt), + lastConnectedAt: lastConnectedAt == null && nullToAbsent + ? const Value.absent() + : Value(lastConnectedAt), + ); + } + + factory ConnectedDevice.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ConnectedDevice( + id: serializer.fromJson(json['id']), + deviceName: serializer.fromJson(json['deviceName']), + deviceAddress: serializer.fromJson(json['deviceAddress']), + deviceType: serializer.fromJson(json['deviceType']), + createdAt: serializer.fromJson(json['createdAt']), + lastConnectedAt: serializer.fromJson(json['lastConnectedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'deviceName': serializer.toJson(deviceName), + 'deviceAddress': serializer.toJson(deviceAddress), + 'deviceType': serializer.toJson(deviceType), + 'createdAt': serializer.toJson(createdAt), + 'lastConnectedAt': serializer.toJson(lastConnectedAt), + }; + } + + ConnectedDevice copyWith( + {int? id, + String? deviceName, + String? deviceAddress, + String? deviceType, + DateTime? createdAt, + Value lastConnectedAt = const Value.absent()}) => + ConnectedDevice( + id: id ?? this.id, + deviceName: deviceName ?? this.deviceName, + deviceAddress: deviceAddress ?? this.deviceAddress, + deviceType: deviceType ?? this.deviceType, + createdAt: createdAt ?? this.createdAt, + lastConnectedAt: lastConnectedAt.present + ? lastConnectedAt.value + : this.lastConnectedAt, + ); + ConnectedDevice copyWithCompanion(ConnectedDevicesCompanion data) { + return ConnectedDevice( + id: data.id.present ? data.id.value : this.id, + deviceName: + data.deviceName.present ? data.deviceName.value : this.deviceName, + deviceAddress: data.deviceAddress.present + ? data.deviceAddress.value + : this.deviceAddress, + deviceType: + data.deviceType.present ? data.deviceType.value : this.deviceType, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + lastConnectedAt: data.lastConnectedAt.present + ? data.lastConnectedAt.value + : this.lastConnectedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ConnectedDevice(') + ..write('id: $id, ') + ..write('deviceName: $deviceName, ') + ..write('deviceAddress: $deviceAddress, ') + ..write('deviceType: $deviceType, ') + ..write('createdAt: $createdAt, ') + ..write('lastConnectedAt: $lastConnectedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, deviceName, deviceAddress, deviceType, createdAt, lastConnectedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ConnectedDevice && + other.id == this.id && + other.deviceName == this.deviceName && + other.deviceAddress == this.deviceAddress && + other.deviceType == this.deviceType && + other.createdAt == this.createdAt && + other.lastConnectedAt == this.lastConnectedAt); +} + +class ConnectedDevicesCompanion extends UpdateCompanion { + final Value id; + final Value deviceName; + final Value deviceAddress; + final Value deviceType; + final Value createdAt; + final Value lastConnectedAt; + const ConnectedDevicesCompanion({ + this.id = const Value.absent(), + this.deviceName = const Value.absent(), + this.deviceAddress = const Value.absent(), + this.deviceType = const Value.absent(), + this.createdAt = const Value.absent(), + this.lastConnectedAt = const Value.absent(), + }); + ConnectedDevicesCompanion.insert({ + this.id = const Value.absent(), + required String deviceName, + required String deviceAddress, + required String deviceType, + this.createdAt = const Value.absent(), + this.lastConnectedAt = const Value.absent(), + }) : deviceName = Value(deviceName), + deviceAddress = Value(deviceAddress), + deviceType = Value(deviceType); + static Insertable custom({ + Expression? id, + Expression? deviceName, + Expression? deviceAddress, + Expression? deviceType, + Expression? createdAt, + Expression? lastConnectedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (deviceName != null) 'device_name': deviceName, + if (deviceAddress != null) 'device_address': deviceAddress, + if (deviceType != null) 'device_type': deviceType, + if (createdAt != null) 'created_at': createdAt, + if (lastConnectedAt != null) 'last_connected_at': lastConnectedAt, + }); + } + + ConnectedDevicesCompanion copyWith( + {Value? id, + Value? deviceName, + Value? deviceAddress, + Value? deviceType, + Value? createdAt, + Value? lastConnectedAt}) { + return ConnectedDevicesCompanion( + id: id ?? this.id, + deviceName: deviceName ?? this.deviceName, + deviceAddress: deviceAddress ?? this.deviceAddress, + deviceType: deviceType ?? this.deviceType, + createdAt: createdAt ?? this.createdAt, + lastConnectedAt: lastConnectedAt ?? this.lastConnectedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (deviceName.present) { + map['device_name'] = Variable(deviceName.value); + } + if (deviceAddress.present) { + map['device_address'] = Variable(deviceAddress.value); + } + if (deviceType.present) { + map['device_type'] = Variable(deviceType.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (lastConnectedAt.present) { + map['last_connected_at'] = Variable(lastConnectedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ConnectedDevicesCompanion(') + ..write('id: $id, ') + ..write('deviceName: $deviceName, ') + ..write('deviceAddress: $deviceAddress, ') + ..write('deviceType: $deviceType, ') + ..write('createdAt: $createdAt, ') + ..write('lastConnectedAt: $lastConnectedAt') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $ConnectedDevicesTable connectedDevices = + $ConnectedDevicesTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [connectedDevices]; +} + +typedef $$ConnectedDevicesTableCreateCompanionBuilder + = ConnectedDevicesCompanion Function({ + Value id, + required String deviceName, + required String deviceAddress, + required String deviceType, + Value createdAt, + Value lastConnectedAt, +}); +typedef $$ConnectedDevicesTableUpdateCompanionBuilder + = ConnectedDevicesCompanion Function({ + Value id, + Value deviceName, + Value deviceAddress, + Value deviceType, + Value createdAt, + Value lastConnectedAt, +}); + +class $$ConnectedDevicesTableFilterComposer + extends Composer<_$AppDatabase, $ConnectedDevicesTable> { + $$ConnectedDevicesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get deviceName => $composableBuilder( + column: $table.deviceName, builder: (column) => ColumnFilters(column)); + + ColumnFilters get deviceAddress => $composableBuilder( + column: $table.deviceAddress, builder: (column) => ColumnFilters(column)); + + ColumnFilters get deviceType => $composableBuilder( + column: $table.deviceType, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get lastConnectedAt => $composableBuilder( + column: $table.lastConnectedAt, + builder: (column) => ColumnFilters(column)); +} + +class $$ConnectedDevicesTableOrderingComposer + extends Composer<_$AppDatabase, $ConnectedDevicesTable> { + $$ConnectedDevicesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get deviceName => $composableBuilder( + column: $table.deviceName, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get deviceAddress => $composableBuilder( + column: $table.deviceAddress, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get deviceType => $composableBuilder( + column: $table.deviceType, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get lastConnectedAt => $composableBuilder( + column: $table.lastConnectedAt, + builder: (column) => ColumnOrderings(column)); +} + +class $$ConnectedDevicesTableAnnotationComposer + extends Composer<_$AppDatabase, $ConnectedDevicesTable> { + $$ConnectedDevicesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get deviceName => $composableBuilder( + column: $table.deviceName, builder: (column) => column); + + GeneratedColumn get deviceAddress => $composableBuilder( + column: $table.deviceAddress, builder: (column) => column); + + GeneratedColumn get deviceType => $composableBuilder( + column: $table.deviceType, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get lastConnectedAt => $composableBuilder( + column: $table.lastConnectedAt, builder: (column) => column); +} + +class $$ConnectedDevicesTableTableManager extends RootTableManager< + _$AppDatabase, + $ConnectedDevicesTable, + ConnectedDevice, + $$ConnectedDevicesTableFilterComposer, + $$ConnectedDevicesTableOrderingComposer, + $$ConnectedDevicesTableAnnotationComposer, + $$ConnectedDevicesTableCreateCompanionBuilder, + $$ConnectedDevicesTableUpdateCompanionBuilder, + ( + ConnectedDevice, + BaseReferences<_$AppDatabase, $ConnectedDevicesTable, ConnectedDevice> + ), + ConnectedDevice, + PrefetchHooks Function()> { + $$ConnectedDevicesTableTableManager( + _$AppDatabase db, $ConnectedDevicesTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ConnectedDevicesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ConnectedDevicesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ConnectedDevicesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value deviceName = const Value.absent(), + Value deviceAddress = const Value.absent(), + Value deviceType = const Value.absent(), + Value createdAt = const Value.absent(), + Value lastConnectedAt = const Value.absent(), + }) => + ConnectedDevicesCompanion( + id: id, + deviceName: deviceName, + deviceAddress: deviceAddress, + deviceType: deviceType, + createdAt: createdAt, + lastConnectedAt: lastConnectedAt, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required String deviceName, + required String deviceAddress, + required String deviceType, + Value createdAt = const Value.absent(), + Value lastConnectedAt = const Value.absent(), + }) => + ConnectedDevicesCompanion.insert( + id: id, + deviceName: deviceName, + deviceAddress: deviceAddress, + deviceType: deviceType, + createdAt: createdAt, + lastConnectedAt: lastConnectedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$ConnectedDevicesTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $ConnectedDevicesTable, + ConnectedDevice, + $$ConnectedDevicesTableFilterComposer, + $$ConnectedDevicesTableOrderingComposer, + $$ConnectedDevicesTableAnnotationComposer, + $$ConnectedDevicesTableCreateCompanionBuilder, + $$ConnectedDevicesTableUpdateCompanionBuilder, + ( + ConnectedDevice, + BaseReferences<_$AppDatabase, $ConnectedDevicesTable, ConnectedDevice> + ), + ConnectedDevice, + PrefetchHooks Function()>; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$ConnectedDevicesTableTableManager get connectedDevices => + $$ConnectedDevicesTableTableManager(_db, _db.connectedDevices); +} + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$nConnectedDevicesHash() => r'022e744d950bb37c1016266064639e51ce6031b5'; + +/// See also [NConnectedDevices]. +@ProviderFor(NConnectedDevices) +final nConnectedDevicesProvider = AutoDisposeAsyncNotifierProvider< + NConnectedDevices, List>.internal( + NConnectedDevices.new, + name: r'nConnectedDevicesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$nConnectedDevicesHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$NConnectedDevices = AutoDisposeAsyncNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/main.dart b/lib/main.dart index 3151c6d..f2e1c26 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,19 +1,23 @@ import 'package:abawo_bt_app/pages/devices_page.dart'; +import 'package:abawo_bt_app/src/rust/frb_generated.dart'; import 'package:abawo_bt_app/util/sharedPrefs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nb_utils/nb_utils.dart'; import 'pages/home_page.dart'; import 'pages/settings_page.dart'; +import 'package:abawo_bt_app/pages/device_details_page.dart'; Future main() async { Logger.root.level = Level.ALL; // defaults to Level.INFO Logger.root.onRecord.listen((record) { print('${record.level.name}: ${record.time}: ${record.message}'); }); + await RustLib.init(); WidgetsFlutterBinding.ensureInitialized(); + await initialize(); final prefs = await SharedPreferences.getInstance(); @@ -49,6 +53,7 @@ class AbawoBtApp extends StatelessWidget { // Configure GoRouter final _router = GoRouter( + navigatorKey: navigatorKey, initialLocation: '/', routes: [ GoRoute( @@ -65,5 +70,42 @@ final _router = GoRouter( ) ], ), + GoRoute( + path: '/device/:deviceAddress', + builder: (context, state) { + final deviceAddress = state.pathParameters['deviceAddress']!; + return DeviceDetailsPage(deviceAddress: deviceAddress); + }, + ), ], ); + +/* + +import 'package:flutter/material.dart'; +import 'package:abawo_bt_app/src/rust/api/simple.dart'; +import 'package:abawo_bt_app/src/rust/frb_generated.dart'; + +Future main() async { + await RustLib.init(); + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('flutter_rust_bridge quickstart')), + body: Center( + child: Text( + 'Action: Call Rust `greet("Tom")`\nResult: `${greet(name: "Tom")}`'), + ), + ), + ); + } +} + +*/ diff --git a/lib/model/bluetooth_device_model.dart b/lib/model/bluetooth_device_model.dart index 9b0cd76..93687ee 100644 --- a/lib/model/bluetooth_device_model.dart +++ b/lib/model/bluetooth_device_model.dart @@ -1,3 +1,6 @@ +import 'package:abawo_bt_app/util/constants.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' show DeviceIdentifier; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'bluetooth_device_model.freezed.dart'; @@ -12,6 +15,24 @@ enum DeviceType { other, } +DeviceType deviceTypeFromUuids(List uuids) { + if (uuids.any((uuid) => isAbawoUniversalShiftersDeviceGuid(uuid))) { + return DeviceType.universalShifters; + } + return DeviceType.other; +} + +DeviceType deviceTypeFromString(String type) { + return DeviceType.values.firstWhere( + (e) => e.toString().split('.').last == type, + orElse: () => DeviceType.other, + ); +} + +String deviceTypeToString(DeviceType type) { + return type.toString().split('.').last; +} + /// Model representing a Bluetooth device @freezed abstract class BluetoothDeviceModel with _$BluetoothDeviceModel { @@ -25,23 +46,28 @@ abstract class BluetoothDeviceModel with _$BluetoothDeviceModel { /// MAC address of the device required String address, - /// Signal strength indicator (RSSI) - int? rssi, - /// Type of the device @Default(DeviceType.other) DeviceType type, - /// Whether the device is currently connected - @Default(false) bool isConnected, - /// Additional device information Map? manufacturerData, - /// Service UUIDs advertised by the device - List? serviceUuids, + /// Identifier of the device + @DeviceIdentJsonConverter() required DeviceIdentifier deviceIdent, }) = _BluetoothDeviceModel; /// Create a BluetoothDeviceModel from JSON factory BluetoothDeviceModel.fromJson(Map json) => _$BluetoothDeviceModelFromJson(json); } + +class DeviceIdentJsonConverter + implements JsonConverter { + const DeviceIdentJsonConverter(); + + @override + DeviceIdentifier fromJson(String json) => DeviceIdentifier(json); + + @override + String toJson(DeviceIdentifier object) => object.str; +} diff --git a/lib/model/bluetooth_device_model.freezed.dart b/lib/model/bluetooth_device_model.freezed.dart index 4ab5782..4545b68 100644 --- a/lib/model/bluetooth_device_model.freezed.dart +++ b/lib/model/bluetooth_device_model.freezed.dart @@ -24,20 +24,15 @@ mixin _$BluetoothDeviceModel { /// MAC address of the device String get address; - /// Signal strength indicator (RSSI) - int? get rssi; - /// Type of the device DeviceType get type; - /// Whether the device is currently connected - bool get isConnected; - /// Additional device information Map? get manufacturerData; - /// Service UUIDs advertised by the device - List? get serviceUuids; + /// Identifier of the device + @DeviceIdentJsonConverter() + DeviceIdentifier get deviceIdent; /// Create a copy of BluetoothDeviceModel /// with the given fields replaced by the non-null parameter values. @@ -58,32 +53,21 @@ mixin _$BluetoothDeviceModel { (identical(other.id, id) || other.id == id) && (identical(other.name, name) || other.name == name) && (identical(other.address, address) || other.address == address) && - (identical(other.rssi, rssi) || other.rssi == rssi) && (identical(other.type, type) || other.type == type) && - (identical(other.isConnected, isConnected) || - other.isConnected == isConnected) && const DeepCollectionEquality() .equals(other.manufacturerData, manufacturerData) && - const DeepCollectionEquality() - .equals(other.serviceUuids, serviceUuids)); + (identical(other.deviceIdent, deviceIdent) || + other.deviceIdent == deviceIdent)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash( - runtimeType, - id, - name, - address, - rssi, - type, - isConnected, - const DeepCollectionEquality().hash(manufacturerData), - const DeepCollectionEquality().hash(serviceUuids)); + int get hashCode => Object.hash(runtimeType, id, name, address, type, + const DeepCollectionEquality().hash(manufacturerData), deviceIdent); @override String toString() { - return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, rssi: $rssi, type: $type, isConnected: $isConnected, manufacturerData: $manufacturerData, serviceUuids: $serviceUuids)'; + return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, type: $type, manufacturerData: $manufacturerData, deviceIdent: $deviceIdent)'; } } @@ -97,11 +81,9 @@ abstract mixin class $BluetoothDeviceModelCopyWith<$Res> { {String id, String? name, String address, - int? rssi, DeviceType type, - bool isConnected, Map? manufacturerData, - List? serviceUuids}); + @DeviceIdentJsonConverter() DeviceIdentifier deviceIdent}); } /// @nodoc @@ -120,11 +102,9 @@ class _$BluetoothDeviceModelCopyWithImpl<$Res> Object? id = null, Object? name = freezed, Object? address = null, - Object? rssi = freezed, Object? type = null, - Object? isConnected = null, Object? manufacturerData = freezed, - Object? serviceUuids = freezed, + Object? deviceIdent = null, }) { return _then(_self.copyWith( id: null == id @@ -139,26 +119,18 @@ class _$BluetoothDeviceModelCopyWithImpl<$Res> ? _self.address : address // ignore: cast_nullable_to_non_nullable as String, - rssi: freezed == rssi - ? _self.rssi - : rssi // ignore: cast_nullable_to_non_nullable - as int?, type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable as DeviceType, - isConnected: null == isConnected - ? _self.isConnected - : isConnected // ignore: cast_nullable_to_non_nullable - as bool, manufacturerData: freezed == manufacturerData ? _self.manufacturerData : manufacturerData // ignore: cast_nullable_to_non_nullable as Map?, - serviceUuids: freezed == serviceUuids - ? _self.serviceUuids - : serviceUuids // ignore: cast_nullable_to_non_nullable - as List?, + deviceIdent: null == deviceIdent + ? _self.deviceIdent + : deviceIdent // ignore: cast_nullable_to_non_nullable + as DeviceIdentifier, )); } } @@ -170,13 +142,10 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel { {required this.id, this.name, required this.address, - this.rssi, this.type = DeviceType.other, - this.isConnected = false, final Map? manufacturerData, - final List? serviceUuids}) - : _manufacturerData = manufacturerData, - _serviceUuids = serviceUuids; + @DeviceIdentJsonConverter() required this.deviceIdent}) + : _manufacturerData = manufacturerData; factory _BluetoothDeviceModel.fromJson(Map json) => _$BluetoothDeviceModelFromJson(json); @@ -192,20 +161,11 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel { @override final String address; - /// Signal strength indicator (RSSI) - @override - final int? rssi; - /// Type of the device @override @JsonKey() final DeviceType type; - /// Whether the device is currently connected - @override - @JsonKey() - final bool isConnected; - /// Additional device information final Map? _manufacturerData; @@ -219,18 +179,10 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel { return EqualUnmodifiableMapView(value); } - /// Service UUIDs advertised by the device - final List? _serviceUuids; - - /// Service UUIDs advertised by the device + /// Identifier of the device @override - List? get serviceUuids { - final value = _serviceUuids; - if (value == null) return null; - if (_serviceUuids is EqualUnmodifiableListView) return _serviceUuids; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(value); - } + @DeviceIdentJsonConverter() + final DeviceIdentifier deviceIdent; /// Create a copy of BluetoothDeviceModel /// with the given fields replaced by the non-null parameter values. @@ -256,32 +208,21 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel { (identical(other.id, id) || other.id == id) && (identical(other.name, name) || other.name == name) && (identical(other.address, address) || other.address == address) && - (identical(other.rssi, rssi) || other.rssi == rssi) && (identical(other.type, type) || other.type == type) && - (identical(other.isConnected, isConnected) || - other.isConnected == isConnected) && const DeepCollectionEquality() .equals(other._manufacturerData, _manufacturerData) && - const DeepCollectionEquality() - .equals(other._serviceUuids, _serviceUuids)); + (identical(other.deviceIdent, deviceIdent) || + other.deviceIdent == deviceIdent)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash( - runtimeType, - id, - name, - address, - rssi, - type, - isConnected, - const DeepCollectionEquality().hash(_manufacturerData), - const DeepCollectionEquality().hash(_serviceUuids)); + int get hashCode => Object.hash(runtimeType, id, name, address, type, + const DeepCollectionEquality().hash(_manufacturerData), deviceIdent); @override String toString() { - return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, rssi: $rssi, type: $type, isConnected: $isConnected, manufacturerData: $manufacturerData, serviceUuids: $serviceUuids)'; + return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, type: $type, manufacturerData: $manufacturerData, deviceIdent: $deviceIdent)'; } } @@ -297,11 +238,9 @@ abstract mixin class _$BluetoothDeviceModelCopyWith<$Res> {String id, String? name, String address, - int? rssi, DeviceType type, - bool isConnected, Map? manufacturerData, - List? serviceUuids}); + @DeviceIdentJsonConverter() DeviceIdentifier deviceIdent}); } /// @nodoc @@ -320,11 +259,9 @@ class __$BluetoothDeviceModelCopyWithImpl<$Res> Object? id = null, Object? name = freezed, Object? address = null, - Object? rssi = freezed, Object? type = null, - Object? isConnected = null, Object? manufacturerData = freezed, - Object? serviceUuids = freezed, + Object? deviceIdent = null, }) { return _then(_BluetoothDeviceModel( id: null == id @@ -339,26 +276,18 @@ class __$BluetoothDeviceModelCopyWithImpl<$Res> ? _self.address : address // ignore: cast_nullable_to_non_nullable as String, - rssi: freezed == rssi - ? _self.rssi - : rssi // ignore: cast_nullable_to_non_nullable - as int?, type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable as DeviceType, - isConnected: null == isConnected - ? _self.isConnected - : isConnected // ignore: cast_nullable_to_non_nullable - as bool, manufacturerData: freezed == manufacturerData ? _self._manufacturerData : manufacturerData // ignore: cast_nullable_to_non_nullable as Map?, - serviceUuids: freezed == serviceUuids - ? _self._serviceUuids - : serviceUuids // ignore: cast_nullable_to_non_nullable - as List?, + deviceIdent: null == deviceIdent + ? _self.deviceIdent + : deviceIdent // ignore: cast_nullable_to_non_nullable + as DeviceIdentifier, )); } } diff --git a/lib/model/bluetooth_device_model.g.dart b/lib/model/bluetooth_device_model.g.dart index 68050de..75f1424 100644 --- a/lib/model/bluetooth_device_model.g.dart +++ b/lib/model/bluetooth_device_model.g.dart @@ -12,14 +12,11 @@ _BluetoothDeviceModel _$BluetoothDeviceModelFromJson( id: json['id'] as String, name: json['name'] as String?, address: json['address'] as String, - rssi: (json['rssi'] as num?)?.toInt(), type: $enumDecodeNullable(_$DeviceTypeEnumMap, json['type']) ?? DeviceType.other, - isConnected: json['isConnected'] as bool? ?? false, manufacturerData: json['manufacturerData'] as Map?, - serviceUuids: (json['serviceUuids'] as List?) - ?.map((e) => e as String) - .toList(), + deviceIdent: const DeviceIdentJsonConverter() + .fromJson(json['deviceIdent'] as String), ); Map _$BluetoothDeviceModelToJson( @@ -28,11 +25,10 @@ Map _$BluetoothDeviceModelToJson( 'id': instance.id, 'name': instance.name, 'address': instance.address, - 'rssi': instance.rssi, 'type': _$DeviceTypeEnumMap[instance.type]!, - 'isConnected': instance.isConnected, 'manufacturerData': instance.manufacturerData, - 'serviceUuids': instance.serviceUuids, + 'deviceIdent': + const DeviceIdentJsonConverter().toJson(instance.deviceIdent), }; const _$DeviceTypeEnumMap = { diff --git a/lib/model/shifter_types.dart b/lib/model/shifter_types.dart new file mode 100644 index 0000000..9d34111 --- /dev/null +++ b/lib/model/shifter_types.dart @@ -0,0 +1,299 @@ +import 'package:cbor/simple.dart'; + +const String universalShifterControlServiceUuid = + '0993826f-0ee4-4b37-9614-d13ecba4ffc2'; +const String universalShifterStatusCharacteristicUuid = + '0993826f-0ee4-4b37-9614-d13ecba40000'; +const String universalShifterConnectToAddrCharacteristicUuid = + '0993826f-0ee4-4b37-9614-d13ecba40001'; +const String universalShifterCommandCharacteristicUuid = + '0993826f-0ee4-4b37-9614-d13ecba40005'; +const String universalShifterGearRatiosCharacteristicUuid = + '0993826f-0ee4-4b37-9614-d13ecba40006'; +const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb'; + +const int errorSequence = 1; +const int errorFtmsMissing = 2; +const int errorPairingAuth = 3; +const int errorPairingEncrypt = 4; +const int errorFtmsRequiredCharMissing = 5; + +class ShifterErrorInfo { + const ShifterErrorInfo({ + required this.code, + required this.title, + required this.details, + }); + + final int code; + final String title; + final String details; +} + +ShifterErrorInfo shifterErrorInfo(int code) { + switch (code) { + case errorSequence: + return const ShifterErrorInfo( + code: errorSequence, + title: 'Invalid command sequence', + details: + 'The button received Connect without a fresh target address. The app must write connect_to_addr first, then the connect command.', + ); + case errorFtmsMissing: + return const ShifterErrorInfo( + code: errorFtmsMissing, + title: 'FTMS service missing', + details: + 'The selected bike does not expose the FTMS service (UUID 0x1826), so pairing cannot continue.', + ); + case errorPairingAuth: + return const ShifterErrorInfo( + code: errorPairingAuth, + title: 'Pairing authentication failed', + details: + 'Bonding authentication with the bike failed. Remove old bonds on both devices and try pairing again nearby.', + ); + case errorPairingEncrypt: + return const ShifterErrorInfo( + code: errorPairingEncrypt, + title: 'Pairing/encryption failed', + details: + 'The secure link to the bike could not be established. Retry close to the bike and ensure it is pairable.', + ); + case errorFtmsRequiredCharMissing: + return const ShifterErrorInfo( + code: errorFtmsRequiredCharMissing, + title: 'Required FTMS characteristic missing', + details: + 'The bike has FTMS but is missing required characteristics (for example Indoor Bike Data), so control cannot start.', + ); + default: + return ShifterErrorInfo( + code: code, + title: 'Unknown error', + details: 'The button reported an unknown error code ($code).', + ); + } +} + +enum UniversalShifterCommand { + reset(0x00), + startScan(0x01), + stopScan(0x02), + connectToDevice(0x03), + disconnect(0x04), + turnOff(0x05); + + const UniversalShifterCommand(this.value); + final int value; +} + +enum ControlConnectionState { + disconnected, + connected; + + static ControlConnectionState fromRaw(dynamic raw) { + if (raw is int) { + return raw == 1 + ? ControlConnectionState.connected + : ControlConnectionState.disconnected; + } + if (raw is String) { + final normalized = raw.toLowerCase(); + if (normalized.contains('connected')) { + return ControlConnectionState.connected; + } + } + return ControlConnectionState.disconnected; + } +} + +enum TrainerConnectionState { + idle, + connecting, + pairing, + discoveringFtms, + ftmsReady, + error, +} + +class TrainerStatus { + const TrainerStatus({required this.state, this.errorCode}); + + final TrainerConnectionState state; + final int? errorCode; + + String get label { + switch (state) { + case TrainerConnectionState.idle: + return 'Idle'; + case TrainerConnectionState.connecting: + return 'Connecting'; + case TrainerConnectionState.pairing: + return 'Pairing'; + case TrainerConnectionState.discoveringFtms: + return 'Discovering FTMS'; + case TrainerConnectionState.ftmsReady: + return 'FTMS Ready'; + case TrainerConnectionState.error: + return 'Error${errorCode != null ? ' ($errorCode)' : ''}'; + } + } + + static TrainerStatus fromRaw(dynamic raw) { + if (raw is int) { + switch (raw) { + case 1: + return const TrainerStatus(state: TrainerConnectionState.connecting); + case 2: + return const TrainerStatus(state: TrainerConnectionState.pairing); + case 3: + return const TrainerStatus( + state: TrainerConnectionState.discoveringFtms); + case 4: + return const TrainerStatus(state: TrainerConnectionState.ftmsReady); + default: + return const TrainerStatus(state: TrainerConnectionState.idle); + } + } + + if (raw is List && raw.isNotEmpty) { + final variant = raw.first; + final value = raw.length > 1 ? raw[1] : null; + if (variant is int && variant == 5) { + return TrainerStatus( + state: TrainerConnectionState.error, + errorCode: value is int ? value : null, + ); + } + } + + if (raw is Map) { + final entry = raw.entries.isNotEmpty ? raw.entries.first : null; + if (entry != null) { + final key = entry.key; + final value = entry.value; + if ((key is int && key == 5) || + (key is String && key.toLowerCase().contains('error'))) { + return TrainerStatus( + state: TrainerConnectionState.error, + errorCode: value is int ? value : null, + ); + } + } + } + + if (raw is String) { + final normalized = raw.toLowerCase(); + if (normalized.contains('connecting')) { + return const TrainerStatus(state: TrainerConnectionState.connecting); + } + if (normalized.contains('pairing')) { + return const TrainerStatus(state: TrainerConnectionState.pairing); + } + if (normalized.contains('discover')) { + return const TrainerStatus( + state: TrainerConnectionState.discoveringFtms); + } + if (normalized.contains('ready')) { + return const TrainerStatus(state: TrainerConnectionState.ftmsReady); + } + if (normalized.contains('error')) { + return const TrainerStatus(state: TrainerConnectionState.error); + } + } + + return const TrainerStatus(state: TrainerConnectionState.idle); + } +} + +class CentralStatus { + const CentralStatus({ + required this.control, + required this.trainer, + required this.hasSavedBond, + required this.connectedTrainerAddr, + required this.lastFailure, + required this.raw, + }); + + final ControlConnectionState control; + final TrainerStatus trainer; + final bool hasSavedBond; + final List? connectedTrainerAddr; + final int? lastFailure; + final dynamic raw; + + String get statusLine => + 'Control: ${control.name}, Trainer: ${trainer.label}${lastFailure != null ? ', Last failure: $lastFailure' : ''}'; + + static CentralStatus fromCborBytes(List bytes) { + final decoded = cbor.decode(bytes); + if (decoded is! Map) { + return CentralStatus( + control: ControlConnectionState.disconnected, + trainer: const TrainerStatus(state: TrainerConnectionState.idle), + hasSavedBond: false, + connectedTrainerAddr: null, + lastFailure: null, + raw: decoded, + ); + } + + final controlRaw = _readMapValue(decoded, [0, 'control']); + final trainerRaw = _readMapValue(decoded, [1, 'trainer']); + final hasSavedBondRaw = _readMapValue(decoded, [2, 'has_saved_bond']); + final connectedTrainerAddrRaw = + _readMapValue(decoded, [3, 'connected_trainer_addr']); + final lastFailureRaw = _readMapValue(decoded, [4, 'last_failure']); + + return CentralStatus( + control: ControlConnectionState.fromRaw(controlRaw), + trainer: TrainerStatus.fromRaw(trainerRaw), + hasSavedBond: hasSavedBondRaw is bool ? hasSavedBondRaw : false, + connectedTrainerAddr: _toByteList(connectedTrainerAddrRaw), + lastFailure: lastFailureRaw is int ? lastFailureRaw : null, + raw: decoded, + ); + } +} + +dynamic _readMapValue(Map map, List keys) { + for (final key in keys) { + if (map.containsKey(key)) { + return map[key]; + } + } + return null; +} + +List? _toByteList(dynamic value) { + if (value == null) { + return null; + } + if (value is List) { + return value.whereType().toList(growable: false); + } + return null; +} + +List parseMacToLittleEndianBytes(String macAddress) { + final compact = macAddress.replaceAll(':', '').replaceAll('-', ''); + if (compact.length != 12) { + throw FormatException('Invalid MAC address format: $macAddress'); + } + final bytes = []; + for (int i = 0; i < compact.length; i += 2) { + bytes.add(int.parse(compact.substring(i, i + 2), radix: 16)); + } + return bytes.reversed.toList(growable: false); +} + +String formatMacAddressFromLittleEndian(List bytes) { + if (bytes.length != 6) { + return 'Unknown'; + } + return bytes.reversed + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(':'); +} diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart new file mode 100644 index 0000000..f8dd297 --- /dev/null +++ b/lib/pages/device_details_page.dart @@ -0,0 +1,740 @@ +import 'dart:async'; + +import 'package:abawo_bt_app/model/shifter_types.dart'; +import 'package:abawo_bt_app/service/shifter_service.dart'; +import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart'; +import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:nb_utils/nb_utils.dart'; + +import '../controller/bluetooth.dart'; +import '../database/database.dart'; + +class DeviceDetailsPage extends ConsumerStatefulWidget { + const DeviceDetailsPage({ + required this.deviceAddress, + super.key, + }); + + final String deviceAddress; + + @override + ConsumerState createState() => _DeviceDetailsPageState(); +} + +class _DeviceDetailsPageState extends ConsumerState { + static const List _keAntRatios = [ + 0.35, + 0.40, + 0.47, + 0.54, + 0.61, + 0.69, + 0.82, + 0.95, + 1.13, + 1.29, + 1.50, + 1.71, + 1.89, + 2.12, + 2.40, + 2.77, + 3.27, + ]; + + bool _isReconnecting = false; + bool _wasConnectedToCurrentDevice = false; + bool _isExitingPage = false; + bool _hasRequestedDisconnect = false; + Timer? _reconnectTimeoutTimer; + ProviderSubscription>? + _connectionStatusSubscription; + + ShifterService? _shifterService; + StreamSubscription? _statusSubscription; + CentralStatus? _latestStatus; + final List<_StatusHistoryEntry> _statusHistory = []; + + bool _isGearRatiosLoading = false; + bool _hasLoadedGearRatios = false; + String? _gearRatiosError; + List _gearRatios = const []; + + @override + void initState() { + super.initState(); + _connectionStatusSubscription = + ref.listenManual>( + connectionStatusProvider, + (_, next) { + final data = next.valueOrNull; + if (data == null) { + return; + } + _onConnectionStatusChanged(data); + }, + fireImmediately: true, + ); + } + + @override + void dispose() { + unawaited(_disconnectOnClose()); + _reconnectTimeoutTimer?.cancel(); + _connectionStatusSubscription?.close(); + _statusSubscription?.cancel(); + _shifterService?.dispose(); + super.dispose(); + } + + Future _disconnectOnClose() async { + if (_hasRequestedDisconnect) { + return; + } + + _hasRequestedDisconnect = true; + _isExitingPage = true; + _reconnectTimeoutTimer?.cancel(); + + final bluetooth = ref.read(bluetoothProvider).value; + await bluetooth?.disconnect(); + await _stopStatusStreaming(); + } + + void _onConnectionStatusChanged((ConnectionStatus, String?) data) { + if (!mounted || _isExitingPage) { + return; + } + + final (status, connectedDeviceId) = data; + final isCurrentDevice = connectedDeviceId == widget.deviceAddress; + + if (isCurrentDevice && status == ConnectionStatus.connected) { + _wasConnectedToCurrentDevice = true; + _startStatusStreamingIfNeeded(); + if (_isReconnecting) { + _reconnectTimeoutTimer?.cancel(); + setState(() { + _isReconnecting = false; + }); + } + return; + } + + if (_wasConnectedToCurrentDevice && + !_isReconnecting && + status == ConnectionStatus.disconnected) { + _startReconnect(); + } + + if (!isCurrentDevice || status == ConnectionStatus.disconnected) { + _stopStatusStreaming(); + } + } + + Future _startReconnect() async { + if (!mounted || _isExitingPage || _isReconnecting) { + return; + } + + setState(() { + _isReconnecting = true; + }); + + final bluetooth = ref.read(bluetoothProvider).value; + await bluetooth?.connectById(widget.deviceAddress); + + _reconnectTimeoutTimer?.cancel(); + _reconnectTimeoutTimer = Timer(const Duration(seconds: 10), () { + if (!mounted || !_isReconnecting || _isExitingPage) { + return; + } + _terminateConnectionAndGoHome( + 'Connection lost. Could not reconnect in time.', + ); + }); + } + + Future _startStatusStreamingIfNeeded() async { + if (_shifterService != null) { + if (!_hasLoadedGearRatios && !_isGearRatiosLoading) { + unawaited(_loadGearRatios()); + } + return; + } + final asyncBluetooth = ref.read(bluetoothProvider); + final BluetoothController bluetooth; + if (asyncBluetooth.hasValue) { + bluetooth = asyncBluetooth.requireValue; + } else { + bluetooth = await ref.read(bluetoothProvider.future); + } + final service = ShifterService( + bluetooth: bluetooth, + buttonDeviceId: widget.deviceAddress, + ); + + final initialStatusResult = await service.readStatus(); + if (mounted && initialStatusResult.isOk()) { + _recordStatus(initialStatusResult.unwrap()); + } + + _statusSubscription = service.statusStream.listen((status) { + if (!mounted) { + return; + } + _recordStatus(status); + }); + + service.startStatusNotifications(); + setState(() { + _shifterService = service; + }); + unawaited(_loadGearRatios()); + } + + void _recordStatus(CentralStatus status) { + setState(() { + _latestStatus = status; + _statusHistory.insert( + 0, + _StatusHistoryEntry( + timestamp: DateTime.now(), + status: status, + ), + ); + if (_statusHistory.length > 100) { + _statusHistory.removeRange(100, _statusHistory.length); + } + }); + } + + Future _stopStatusStreaming() async { + await _statusSubscription?.cancel(); + _statusSubscription = null; + await _shifterService?.dispose(); + _shifterService = null; + } + + Future _loadGearRatios() async { + final shifter = _shifterService; + if (shifter == null || _isGearRatiosLoading) { + return; + } + + setState(() { + _isGearRatiosLoading = true; + _gearRatiosError = null; + }); + + final result = await shifter.readGearRatios(); + if (!mounted) { + return; + } + + if (result.isErr()) { + setState(() { + _gearRatiosError = 'Failed to read gear ratios: ${result.unwrapErr()}'; + _isGearRatiosLoading = false; + _hasLoadedGearRatios = false; + }); + return; + } + + setState(() { + _gearRatios = result.unwrap(); + _isGearRatiosLoading = false; + _hasLoadedGearRatios = true; + _gearRatiosError = null; + }); + } + + Future _saveGearRatios(List ratios) async { + final shifter = _shifterService; + if (shifter == null) { + return 'Status channel is not ready yet.'; + } + + final result = await shifter.writeGearRatios(ratios); + if (result.isErr()) { + return 'Could not save gear ratios: ${result.unwrapErr()}'; + } + + if (!mounted) { + return null; + } + + setState(() { + _gearRatios = List.from(ratios); + _hasLoadedGearRatios = true; + _gearRatiosError = null; + }); + + return null; + } + + Future _connectButtonToBike() async { + final selectedBike = await BikeScanDialog.show( + context, + excludedDeviceId: widget.deviceAddress, + ); + if (selectedBike == null || !mounted) { + return; + } + + await _startStatusStreamingIfNeeded(); + final shifter = _shifterService; + if (shifter == null) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Status channel is not ready yet.')), + ); + return; + } + + final result = await shifter.connectButtonToBike(selectedBike.id); + if (!mounted) { + return; + } + + if (result.isErr()) { + final err = result.unwrapErr(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Connect request failed: $err')), + ); + toast('Connect request failed.'); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Sent connect request for ${selectedBike.id}.')), + ); + } + + Future _terminateConnectionAndGoHome(String toastMessage) async { + await _disconnectOnClose(); + + if (!mounted) { + return; + } + + toast(toastMessage); + context.replace('/'); + } + + Future _cancelReconnect() async { + await _terminateConnectionAndGoHome('Reconnect cancelled.'); + } + + Future _exitPage() async { + await _disconnectOnClose(); + if (!mounted) { + return; + } + context.replace('/'); + } + + void _showStatusHistory() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.72, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + const Expanded( + child: Text( + 'Status Console', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: _statusHistory.isEmpty + ? const Center(child: Text('No status updates yet.')) + : ListView.builder( + itemCount: _statusHistory.length, + itemBuilder: (context, index) { + final item = _statusHistory[index]; + final errorCode = _effectiveErrorCode(item.status); + return ListTile( + dense: true, + visualDensity: VisualDensity.compact, + title: Text( + item.status.statusLine, + style: const TextStyle(fontSize: 13), + ), + subtitle: Text( + _formatTimestamp(item.timestamp), + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + trailing: errorCode == null + ? null + : IconButton( + tooltip: 'Explain error', + onPressed: () { + _showErrorInfoDialog(errorCode); + }, + icon: const Icon(Icons.info_outline), + ), + onTap: errorCode == null + ? null + : () { + _showErrorInfoDialog(errorCode); + }, + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + + String _formatTimestamp(DateTime time) { + final h = time.hour.toString().padLeft(2, '0'); + final m = time.minute.toString().padLeft(2, '0'); + final s = time.second.toString().padLeft(2, '0'); + return '$h:$m:$s'; + } + + int? _effectiveErrorCode(CentralStatus status) { + if (status.trainer.state == TrainerConnectionState.error) { + return status.trainer.errorCode ?? status.lastFailure; + } + return status.lastFailure; + } + + void _showErrorInfoDialog(int errorCode) { + final info = shifterErrorInfo(errorCode); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + icon: const Icon(Icons.info_outline), + title: Text('Error ${info.code}: ${info.title}'), + content: Text(info.details), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final connectionData = ref.watch(connectionStatusProvider).valueOrNull; + final isCurrentConnected = connectionData != null && + connectionData.$1 == ConnectionStatus.connected && + connectionData.$2 == widget.deviceAddress; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, bool? result) { + _exitPage(); + }, + child: Scaffold( + appBar: AppBar( + title: const Text('Device Details'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _exitPage, + ), + ), + body: Stack( + fit: StackFit.expand, + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDeviceInfo(context, ref, widget.deviceAddress), + const SizedBox(height: 16), + _buildConnectionStatus(context, ref, widget.deviceAddress), + const SizedBox(height: 16), + if (isCurrentConnected) ...[ + _StatusBanner( + status: _latestStatus, + onTap: _showStatusHistory, + onErrorInfoTap: _latestStatus == null + ? null + : () { + final code = _effectiveErrorCode(_latestStatus!); + if (code != null) { + _showErrorInfoDialog(code); + } + }, + ), + const SizedBox(height: 16), + GearRatioEditorCard( + ratios: _gearRatios, + isLoading: _isGearRatiosLoading, + errorText: _gearRatiosError, + onRetry: _loadGearRatios, + onSave: _saveGearRatios, + presets: const [ + GearRatioPreset( + name: 'KeAnt Classic', + description: + '17-step baseline from KeAnt cross app gearing.', + ratios: _keAntRatios, + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _connectButtonToBike, + icon: const Icon(Icons.link), + label: const Text('Connect Button to Bike'), + ), + ), + ], + ], + ), + ), + if (_isReconnecting) + Positioned.fill( + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.55), + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + 'Reconnecting...', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + TextButton( + onPressed: _cancelReconnect, + child: const Text('Cancel'), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _StatusHistoryEntry { + const _StatusHistoryEntry({ + required this.timestamp, + required this.status, + }); + + final DateTime timestamp; + final CentralStatus status; +} + +class _StatusBanner extends StatelessWidget { + const _StatusBanner({ + required this.status, + required this.onTap, + this.onErrorInfoTap, + }); + + final CentralStatus? status; + final VoidCallback onTap; + final VoidCallback? onErrorInfoTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final color = _resolveColor(colorScheme); + final text = status?.statusLine ?? 'Waiting for status updates...'; + + return Material( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(Icons.memory, color: color), + const SizedBox(width: 10), + Expanded( + child: Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (onErrorInfoTap != null && + status != null && + (status!.trainer.state == TrainerConnectionState.error || + status!.lastFailure != null)) + IconButton( + tooltip: 'Explain error', + onPressed: onErrorInfoTap, + icon: Icon(Icons.info_outline, color: color), + ), + Icon(Icons.chevron_right, color: color), + ], + ), + ), + ), + ); + } + + Color _resolveColor(ColorScheme scheme) { + final current = status; + if (current == null) { + return scheme.primary; + } + if (current.trainer.state == TrainerConnectionState.error) { + return scheme.error; + } + if (current.trainer.state == TrainerConnectionState.ftmsReady) { + return Colors.green; + } + if (current.trainer.state == TrainerConnectionState.connecting || + current.trainer.state == TrainerConnectionState.pairing || + current.trainer.state == TrainerConnectionState.discoveringFtms) { + return Colors.orange; + } + return scheme.primary; + } +} + +Widget _buildDeviceInfo( + BuildContext context, + WidgetRef ref, + String deviceAddress, +) { + final asyncSavedDevices = ref.watch(nConnectedDevicesProvider); + + return asyncSavedDevices.when( + data: (devices) { + ConnectedDevice? currentDeviceData; + try { + currentDeviceData = devices.firstWhere( + (d) => d.deviceAddress == deviceAddress, + ); + } catch (_) { + currentDeviceData = null; + } + + if (currentDeviceData == null) { + return Center( + child: Text('Device details not found for $deviceAddress.'), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Name: ${currentDeviceData.deviceName}', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Address: ${currentDeviceData.deviceAddress}', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + 'Type: ${currentDeviceData.deviceType}', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stackTrace) => + Center(child: Text('Error loading device info: $error')), + ); +} + +Widget _buildConnectionStatus( + BuildContext context, + WidgetRef ref, + String deviceAddress, +) { + final asyncConnectionStatus = ref.watch(connectionStatusProvider); + + return asyncConnectionStatus.when( + data: (data) { + final (status, connectedDeviceId) = data; + String statusText; + final isCurrentDeviceConnected = + connectedDeviceId != null && connectedDeviceId == deviceAddress; + + if (isCurrentDeviceConnected) { + switch (status) { + case ConnectionStatus.connected: + statusText = 'Status: Connected'; + break; + case ConnectionStatus.connecting: + statusText = 'Status: Connecting...'; + break; + case ConnectionStatus.disconnecting: + statusText = 'Status: Disconnecting...'; + break; + case ConnectionStatus.disconnected: + statusText = 'Status: Disconnected'; + break; + } + } else { + statusText = 'Status: Disconnected'; + } + + return Text(statusText, style: Theme.of(context).textTheme.titleMedium); + }, + loading: () => const Text( + 'Status: Unknown', + style: TextStyle(fontStyle: FontStyle.italic), + ), + error: (error, stackTrace) => Text( + 'Status: Error ($error)', + style: const TextStyle(color: Colors.red), + ), + ); +} diff --git a/lib/pages/devices_page.dart b/lib/pages/devices_page.dart index 8f4a78a..eecdfb6 100644 --- a/lib/pages/devices_page.dart +++ b/lib/pages/devices_page.dart @@ -1,11 +1,17 @@ +import 'dart:io'; + import 'package:abawo_bt_app/controller/bluetooth.dart'; +import 'package:abawo_bt_app/model/bluetooth_device_model.dart'; import 'package:abawo_bt_app/util/constants.dart'; +import 'package:anyhow/anyhow.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; import 'package:abawo_bt_app/widgets/device_listitem.dart'; -import 'package:abawo_bt_app/widgets/scanning_animation.dart'; // Import the new animation widget +import 'package:abawo_bt_app/widgets/horizontal_scanning_animation.dart'; // Import the new horizontal animation +import 'package:abawo_bt_app/database/database.dart'; +import 'package:drift/drift.dart' show Value; const Duration _scanDuration = Duration(seconds: 10); @@ -18,25 +24,20 @@ class ConnectDevicePage extends ConsumerStatefulWidget { class _ConnectDevicePageState extends ConsumerState with TickerProviderStateMixin { - // Use TickerProviderStateMixin for multiple controllers if needed later, good practice - bool _initialScanStarted = false; + // TickerProviderStateMixin is no longer needed as animations are self-contained or handled by StreamBuilder + int _retryScanCounter = 0; // Used to force animation reset + bool _initialScanTriggered = false; // Track if the first scan was requested bool _showOnlyAbawoDevices = true; // State for filtering devices - late AnimationController - _progressController; // Controller for scan duration progress - late AnimationController - _waveAnimationController; // Controller for wave animation - // Function to start scan safely after controller is ready void _startScanIfNeeded(BluetoothController controller) { // Use WidgetsBinding to schedule the scan start after the build phase WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_initialScanStarted && mounted) { + // Start scan only if it hasn't been triggered yet and the widget is mounted + if (!_initialScanTriggered && mounted) { controller.startScan(timeout: _scanDuration); - _startScanProgressAnimation(); // Start scan duration progress animation - _startWaveAnimation(); // Start the wave animation if (mounted) { setState(() { - _initialScanStarted = true; + _initialScanTriggered = true; }); } } @@ -46,64 +47,17 @@ class _ConnectDevicePageState extends ConsumerState @override void initState() { super.initState(); - // Initialize scan progress controller - _progressController = AnimationController( - vsync: this, - duration: _scanDuration, - )..addListener(() { - // Trigger rebuild when animation value changes for the progress indicator - if (mounted) { - setState(() {}); - } - }); - - // Initialize wave animation controller - _waveAnimationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), // New duration: 1.5 seconds - )..repeat(); // Make the wave animation repeat + super.initState(); + // No animation controllers needed here anymore } @override void dispose() { - // Stop animations before disposing - _progressController.stop(); - _waveAnimationController.stop(); - _progressController.dispose(); - _waveAnimationController.dispose(); + // Dispose controllers if they existed (they don't anymore) super.dispose(); } - // Helper method to start/reset scan progress animation - void _startScanProgressAnimation() { - if (mounted) { - _progressController.reset(); - _progressController.forward().whenCompleteOrCancel(() { - // Optional: Add logic if needed when scan progress completes or cancels - }); - } - } - - // Helper method to start the wave animation - void _startWaveAnimation() { - if (mounted && !_waveAnimationController.isAnimating) { - _waveAnimationController.reset(); - _waveAnimationController.repeat(); - } - } - - // Helper method to stop animations when scan finishes/cancels - void _stopAnimations() { - if (mounted) { - if (_progressController.isAnimating) { - _progressController.stop(); - } - if (_waveAnimationController.isAnimating) { - _waveAnimationController.stop(); - } - } - } - + // Removed _startScanProgressAnimation, _startWaveAnimation, _stopAnimations @override Widget build(BuildContext context) { return Scaffold( @@ -134,155 +88,264 @@ class _ConnectDevicePageState extends ConsumerState ) ], ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( + body: Column( + // Use Column instead of Center(Column(...)) + children: [ + const Padding( + padding: EdgeInsets.all(16.0), // Add padding around the title + child: Text( 'Available Devices', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), - const SizedBox(height: 20), - // Use Consumer to react to bluetoothProvider changes - Expanded( - // Allow the Consumer content to expand - child: Consumer(builder: (context, ref, child) { + ), + // Use Consumer to get the BluetoothController + Expanded( + // Allow the device list to take available space + child: Consumer( + builder: (context, ref, child) { final btAsyncValue = ref.watch(bluetoothProvider); + final connectedDevices = + ref.watch(nConnectedDevicesProvider).valueOrNull ?? + const []; + final connectedDeviceAddresses = connectedDevices + .map((device) => device.deviceAddress) + .toSet(); return btAsyncValue.when( - loading: () => const Center( - child: - CircularProgressIndicator()), // Center loading indicator - error: (err, stack) => Center( - child: Text( - 'Error loading Bluetooth: $err')), // Center error + loading: () => + const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text('Error: $err')), data: (controller) { - // Start the initial scan once the controller is ready - // Start initial scan and animation + // Trigger the initial scan if needed _startScanIfNeeded(controller); - // Use StreamBuilder to watch the scanning state - return StreamBuilder( - stream: FlutterBluePlus.isScanning, - initialData: - false, // Default to not scanning before check + // StreamBuilder for Scan Results (Device List) + return StreamBuilder>( + stream: controller.scanResultsStream, + initialData: const [], builder: (context, snapshot) { - final isScanning = snapshot.data ?? false; + final results = snapshot.data ?? []; + // Filter results based on the toggle state + final filteredResults = _showOnlyAbawoDevices + ? results + .where((device) => + device.serviceUuids.any(isAbawoDeviceGuid)) + .toList() + : results; - if (isScanning && _initialScanStarted) { - _startWaveAnimation(); // Ensure wave animation is running - // Show the new scanning wave animation with the progress indicator value - return Center( - // Pass the wave animation controller. The progress value will be added - // to the ScanningWaveAnimation widget itself later. - child: ScanningWaveAnimation( - animation: _waveAnimationController, - progressValue: _progressController - .value, // Pass the scan progress - ), - ); - } else if (!_initialScanStarted) { - // Show placeholder or button to start initial scan if needed, or just empty space - return const SizedBox( - height: 50); // Placeholder before scan starts - } else { - // Scan finished, stop animations and show results - _stopAnimations(); - final results = controller.scanResults; - // Filter results based on the toggle state - final filteredResults = _showOnlyAbawoDevices - ? results - .where((device) => device - .advertisementData.serviceUuids - .contains(Guid(abawoServiceBtUUID))) - .toList() - : results; - - // Use Column + Expanded for ListView + Button layout - return Column( - children: [ - Expanded( - // Allow ListView to take available space - child: filteredResults - .isEmpty // Use filtered list check - ? const Center( - child: Text( - 'No devices found.')) // Center empty text - : ListView.builder( - itemCount: filteredResults - .length, // Use filtered list length - itemBuilder: (context, index) { - final device = filteredResults[ - index]; // Use filtered list - final isAbawoDevice = device - .advertisementData.serviceUuids - .contains( - Guid(abawoServiceBtUUID)); - final deviceName = - device.device.advName.isEmpty - ? 'Unknown Device' - : device.device.advName; - // Use the custom DeviceListItem widget - return InkWell( - // Wrap with InkWell for tap feedback - onTap: () { - if (!isAbawoDevice) { - // Show a snackbar for non-Abawo devices - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar( - content: Text( - 'This app can only connect to abawo devices.'))); - return; - } - // TODO: Implement connect logic - // controller.connectToDevice(device.device); // Pass the BluetoothDevice - // context.go('/control/${device.device.remoteId.str}'); - print( - 'Tapped on ${device.device.remoteId.str}'); - }, - child: DeviceListItem( - deviceName: deviceName, - deviceId: - device.device.remoteId.str, - isUnknownDevice: - device.device.advName.isEmpty, - ), - ); - } // End of itemBuilder - ), - ), - Padding( - // Add padding around the button - padding: const EdgeInsets.all(16.0), - child: ElevatedButton( - onPressed: () { - // Retry scan by calling startScan on the controller - // Ensure _initialScanStarted is true so indicator shows - if (mounted) { - setState(() { - _initialScanStarted = true; - }); - } - controller.startScan( - timeout: _scanDuration); - _startScanProgressAnimation(); // Restart scan progress animation - _startWaveAnimation(); // Ensure wave animation runs on retry - }, - child: const Text('Retry Scan'), - ), - ), - ], - ); + if (!_initialScanTriggered && filteredResults.isEmpty) { + // Show a message or placeholder before the first scan starts or if no devices found initially + return const Center( + child: Text( + 'Scanning for devices...')); // Or CircularProgressIndicator() } + + if (filteredResults.isEmpty && _initialScanTriggered) { + // Show 'No devices found' only after the initial scan was triggered + return const Center(child: Text('No devices found.')); + } + + // Display the list + return ListView.builder( + itemCount: filteredResults.length, + itemBuilder: (context, index) { + final device = filteredResults[index]; + final isAlreadyConnected = + connectedDeviceAddresses.contains(device.id); + final abawoDevice = + device.serviceUuids.any(isAbawoDeviceGuid); + final connectable = device.serviceUuids + .any(isConnectableAbawoDeviceGuid); + final deviceName = device.name.isEmpty + ? 'Unknown Device' + : device.name; + + return InkWell( + onTap: () async { + if (isAlreadyConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'This device is already connected in the app.'), + ), + ); + return; + } + if (!abawoDevice) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'This app can only connect to abawo devices.')), + ); + return; + } else if (!connectable) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'This device is not connectable with the app.')), + ); + return; + } else { + final res = await controller.connect(device); + print('res: $res'); + switch (res) { + case Ok(): + // trigger pairing/permission prompt if needed + if (!Platform.isAndroid) { + controller.readCharacteristic( + device.id, + '0993826f-0ee4-4b37-9614-d13ecba4ffc2', + '0993826f-0ee4-4b37-9614-d13ecba40000'); + } + // Save to DB and navigate + final notifier = ref.read( + nConnectedDevicesProvider.notifier); + final name = device.name.isNotEmpty + ? device.name + : 'Unknown Device'; + final deviceCompanion = + ConnectedDevicesCompanion( + deviceName: Value(name), + deviceAddress: Value(device.id), + deviceType: Value(deviceTypeToString( + deviceTypeFromUuids( + device.serviceUuids))), + lastConnectedAt: Value(DateTime.now()), + ); + final addResult = await notifier + .addConnectedDevice(deviceCompanion); + + // Check if mounted before using context + if (!context.mounted) break; + + if (addResult.isErr()) { + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: Text( + 'Failed to save device: ${addResult.unwrapErr()}')), + ); + } else { + context.go('/device/${device.id}'); + } + break; + case Err(:final v): + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar( + content: Text( + 'Connection unsuccessful:\n${v.toString()}'), + )); + break; + } + } + print('Tapped on ${device.id}'); + }, + child: DeviceListItem( + deviceName: deviceName, + deviceId: device.id, + type: deviceTypeFromUuids(device.serviceUuids), + ), + ); + }, + ); }, ); }, ); - }), + }, ), - ], - ), - ), + ), + // Bottom section: Scanning Animation and Retry Button (visible only when scanning) + Consumer( + // Use Consumer to get the controller for the retry button action + builder: (context, ref, child) { + final btController = ref + .watch(bluetoothProvider) + .asData + ?.value; // Get controller safely + + return StreamBuilder( + stream: btController?.isScanningStream ?? Stream.empty(), + initialData: false, + builder: (context, snapshot) { + final isScanning = snapshot.data ?? false; + + // Show bottom section only if scanning + if (!isScanning) { + // Show only the retry button when not scanning (optional, could be hidden) + // For now, let's keep the button always visible but disabled when not scannable. + // A better approach might be to hide the button when not scanning. + // Let's show the button but potentially disabled later if controller is null. + return Padding( + padding: const EdgeInsets.all(16.0), + child: ElevatedButton( + onPressed: btController != null + ? () { + // Retry scan ONLY when NOT currently scanning + if (mounted) { + setState(() { + _initialScanTriggered = + true; // Ensure state reflects scan attempt + _retryScanCounter++; // Increment key counter + }); + } + btController.startScan(timeout: _scanDuration); + } + : null, // Disable if controller not ready + child: const Text('Retry Scan'), + ), + ); + } + + // If scanning, show animation and button + return Container( + padding: const EdgeInsets.symmetric( + vertical: 8.0, horizontal: 16.0), + decoration: BoxDecoration( + color: Theme.of(context) + .scaffoldBackgroundColor + .withValues(alpha: 0.9), // Slight overlay effect + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, -4), // Shadow upwards + ) + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, // Keep column compact + children: [ + // Pass isScanning and the ValueKey + HorizontalScanningAnimation( + key: ValueKey( + _retryScanCounter), // Force state rebuild on counter change + isScanning: isScanning, + height: 40, + ), + const SizedBox(height: 8), + ElevatedButton( + // Button does nothing if pressed *while* scanning. + // It just indicates the status. + onPressed: null, // Disable button while scanning + style: ElevatedButton.styleFrom( + disabledBackgroundColor: Theme.of(context) + .primaryColor + .withValues(alpha: 0.5), // Custom disabled color + disabledForegroundColor: Colors.white70, + ), + child: + const Text('Scanning...'), // Just indicate status + ), + const SizedBox(height: 8), // Add some bottom padding + ], + ), + ); + }, + ); + }), + ], // End of outer Column children + ), // End of Scaffold ); } } diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index e0715c7..0cfe003 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,4 +1,9 @@ +import 'package:abawo_bt_app/controller/bluetooth.dart'; +import 'package:abawo_bt_app/database/database.dart'; +import 'package:abawo_bt_app/model/bluetooth_device_model.dart'; +import 'package:abawo_bt_app/widgets/device_listitem.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; class HomePage extends StatelessWidget { @@ -53,10 +58,7 @@ class HomePage extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: const Center( - child: Text( - 'No devices connected yet', - style: TextStyle(color: Colors.grey), - ), + child: DevicesList(), ), ), ], @@ -73,3 +75,148 @@ class HomePage extends StatelessWidget { ); } } + +class DevicesList extends ConsumerStatefulWidget { + const DevicesList({super.key}); + + @override + ConsumerState createState() => _DevicesListState(); +} + +class _DevicesListState extends ConsumerState { + String? _connectingDeviceId; // ID of device currently being connected + + Future _removeDevice(ConnectedDevice device) async { + final shouldRemove = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Remove device?'), + content: + Text('Do you want to remove ${device.deviceName} from the app?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Remove'), + ), + ], + ), + ); + + if (shouldRemove != true || !mounted) { + return; + } + + final result = await ref + .read(nConnectedDevicesProvider.notifier) + .deleteConnectedDevice(device.id); + + if (!mounted) { + return; + } + + if (result.isErr()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to remove device: ${result.unwrapErr()}')), + ); + return; + } + + if (_connectingDeviceId == device.deviceAddress) { + setState(() { + _connectingDeviceId = null; + }); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${device.deviceName} removed from the app.')), + ); + } + + @override + Widget build(BuildContext context) { + final asyncDevices = ref.watch(nConnectedDevicesProvider); + + return asyncDevices.when( + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stackTrace) => Center( + child: Text( + 'Error loading devices: ${error.toString()}', + style: TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ), + data: (devices) { + if (devices.isEmpty) { + return const Center( + child: Text( + 'No devices connected yet', + style: TextStyle(color: Colors.grey), + ), + ); + } + + return ListView.builder( + itemCount: devices.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final device = devices[index]; + return InkWell( + onTap: () async { + if (_connectingDeviceId != null) return; + setState(() { + _connectingDeviceId = device.deviceAddress; + }); + + try { + final controller = await ref.read(bluetoothProvider.future); + final result = await controller.connectById( + device.deviceAddress, + timeout: const Duration(seconds: 10), + ); + + if (result.isOk()) { + context.go('/device/${device.deviceAddress}'); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Connection failed. Is the device turned on and in range?'), + duration: const Duration(seconds: 3), + ), + ); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${e.toString()}')), + ); + } finally { + setState(() { + _connectingDeviceId = null; + }); + } + }, + child: DeviceListItem( + deviceName: device.deviceName, + deviceId: device.deviceAddress, + type: deviceTypeFromString(device.deviceType), + isConnecting: device.deviceAddress == _connectingDeviceId, + trailing: IconButton( + tooltip: 'Remove device', + icon: const Icon(Icons.delete_outline), + onPressed: () => _removeDevice(device), + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/service/shifter_service.dart b/lib/service/shifter_service.dart new file mode 100644 index 0000000..4de7d70 --- /dev/null +++ b/lib/service/shifter_service.dart @@ -0,0 +1,179 @@ +import 'dart:async'; + +import 'package:abawo_bt_app/controller/bluetooth.dart'; +import 'package:abawo_bt_app/model/shifter_types.dart'; +import 'package:anyhow/anyhow.dart'; + +class ShifterService { + ShifterService({ + required BluetoothController bluetooth, + required this.buttonDeviceId, + }) : _bluetooth = bluetooth; + + final BluetoothController _bluetooth; + final String buttonDeviceId; + + final StreamController _statusController = + StreamController.broadcast(); + StreamSubscription>? _statusSubscription; + + Stream get statusStream => _statusController.stream; + + static const int _gearRatioSlots = 32; + static const double _maxGearRatio = 255 / 64; + static const int _gearRatioWriteMtu = 64; + + Future> writeConnectToAddress(String bikeDeviceId) async { + try { + final payload = parseMacToLittleEndianBytes(bikeDeviceId); + return _bluetooth.writeCharacteristic( + buttonDeviceId, + universalShifterControlServiceUuid, + universalShifterConnectToAddrCharacteristicUuid, + payload, + ); + } on FormatException catch (e) { + return bail('Could not parse bike address "$bikeDeviceId": $e'); + } catch (e) { + return bail('Failed writing connect address: $e'); + } + } + + Future> writeCommand(UniversalShifterCommand command) { + return _bluetooth.writeCharacteristic( + buttonDeviceId, + universalShifterControlServiceUuid, + universalShifterCommandCharacteristicUuid, + [command.value], + ); + } + + Future> connectButtonToBike(String bikeDeviceId) async { + final addrRes = await writeConnectToAddress(bikeDeviceId); + if (addrRes.isErr()) { + return addrRes; + } + return writeCommand(UniversalShifterCommand.connectToDevice); + } + + Future>> readGearRatios() async { + final readRes = await _bluetooth.readCharacteristic( + buttonDeviceId, + universalShifterControlServiceUuid, + universalShifterGearRatiosCharacteristicUuid, + ); + if (readRes.isErr()) { + return bail(readRes.unwrapErr()); + } + + final raw = readRes.unwrap(); + if (raw.length > _gearRatioSlots) { + return bail( + 'Invalid gear ratio payload length: expected at most $_gearRatioSlots, got ${raw.length}', + ); + } + + final normalizedRaw = List.filled(_gearRatioSlots, 0, growable: false); + for (var i = 0; i < raw.length; i++) { + normalizedRaw[i] = raw[i]; + } + + final ratios = normalizedRaw + .where((v) => v > 0) + .map((v) => _decodeGearRatio(v)) + .toList(growable: false); + return Ok(ratios); + } + + Future> writeGearRatios(List ratios) async { + final mtuResult = + await _bluetooth.requestMtu(buttonDeviceId, mtu: _gearRatioWriteMtu); + if (mtuResult.isErr()) { + return bail( + 'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}'); + } + + final payload = List.filled(_gearRatioSlots, 0, growable: false); + final limit = + ratios.length < _gearRatioSlots ? ratios.length : _gearRatioSlots; + for (var i = 0; i < limit; i++) { + payload[i] = _encodeGearRatio(ratios[i]); + } + + return _bluetooth.writeCharacteristic( + buttonDeviceId, + universalShifterControlServiceUuid, + universalShifterGearRatiosCharacteristicUuid, + payload, + ); + } + + Future> readStatus() async { + final readRes = await _bluetooth.readCharacteristic( + buttonDeviceId, + universalShifterControlServiceUuid, + universalShifterStatusCharacteristicUuid, + ); + if (readRes.isErr()) { + return bail(readRes.unwrapErr()); + } + + try { + return Ok(CentralStatus.fromCborBytes(readRes.unwrap())); + } catch (e) { + return bail('Failed to decode status payload: $e'); + } + } + + void startStatusNotifications() { + if (_statusSubscription != null) { + return; + } + + _statusSubscription = _bluetooth + .subscribeToCharacteristic( + buttonDeviceId, + universalShifterControlServiceUuid, + universalShifterStatusCharacteristicUuid, + ) + .listen( + (data) { + try { + final status = CentralStatus.fromCborBytes(data); + _statusController.add(status); + } catch (_) { + // Ignore malformed payloads but keep stream alive. + } + }, + onError: (_) { + // Keep UI running; reconnection logic is handled elsewhere. + }, + ); + } + + Future stopStatusNotifications() async { + await _statusSubscription?.cancel(); + _statusSubscription = null; + } + + Future dispose() async { + await stopStatusNotifications(); + await _statusController.close(); + } + + int _encodeGearRatio(double value) { + if (value <= 0) { + return 0; + } + final clamped = value.clamp(0, _maxGearRatio); + final scaled = (clamped * 64).round(); + if (scaled <= 0) { + return 1; + } + return scaled.clamp(1, 255); + } + + double _decodeGearRatio(int raw) { + return raw / 64.0; + } +} diff --git a/lib/src/rust/api/simple.dart b/lib/src/rust/api/simple.dart new file mode 100644 index 0000000..b632d8b --- /dev/null +++ b/lib/src/rust/api/simple.dart @@ -0,0 +1,10 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +String greet({required String name}) => + RustLib.instance.api.crateApiSimpleGreet(name: name); diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart new file mode 100644 index 0000000..4d11532 --- /dev/null +++ b/lib/src/rust/frb_generated.dart @@ -0,0 +1,240 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api/simple.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'frb_generated.io.dart' + if (dart.library.js_interop) 'frb_generated.web.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Main entrypoint of the Rust API +class RustLib extends BaseEntrypoint { + @internal + static final instance = RustLib._(); + + RustLib._(); + + /// Initialize flutter_rust_bridge + static Future init({ + RustLibApi? api, + BaseHandler? handler, + ExternalLibrary? externalLibrary, + bool forceSameCodegenVersion = true, + }) async { + await instance.initImpl( + api: api, + handler: handler, + externalLibrary: externalLibrary, + forceSameCodegenVersion: forceSameCodegenVersion, + ); + } + + /// Initialize flutter_rust_bridge in mock mode. + /// No libraries for FFI are loaded. + static void initMock({ + required RustLibApi api, + }) { + instance.initMockImpl( + api: api, + ); + } + + /// Dispose flutter_rust_bridge + /// + /// The call to this function is optional, since flutter_rust_bridge (and everything else) + /// is automatically disposed when the app stops. + static void dispose() => instance.disposeImpl(); + + @override + ApiImplConstructor get apiImplConstructor => + RustLibApiImpl.new; + + @override + WireConstructor get wireConstructor => + RustLibWire.fromExternalLibrary; + + @override + Future executeRustInitializers() async { + await api.crateApiSimpleInitApp(); + } + + @override + ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig => + kDefaultExternalLibraryLoaderConfig; + + @override + String get codegenVersion => '2.11.1'; + + @override + int get rustContentHash => -1918914929; + + static const kDefaultExternalLibraryLoaderConfig = + ExternalLibraryLoaderConfig( + stem: 'rust_lib_abawo_bt_app', + ioDirectory: 'rust/target/release/', + webPrefix: 'pkg/', + ); +} + +abstract class RustLibApi extends BaseApi { + String crateApiSimpleGreet({required String name}); + + Future crateApiSimpleInitApp(); +} + +class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { + RustLibApiImpl({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @override + String crateApiSimpleGreet({required String name}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(name, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 1)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: null, + ), + constMeta: kCrateApiSimpleGreetConstMeta, + argValues: [name], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSimpleGreetConstMeta => const TaskConstMeta( + debugName: "greet", + argNames: ["name"], + ); + + @override + Future crateApiSimpleInitApp() { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 2, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSimpleInitAppConstMeta, + argValues: [], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSimpleInitAppConstMeta => const TaskConstMeta( + debugName: "init_app", + argNames: [], + ); + + @protected + String dco_decode_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as String; + } + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as Uint8List; + } + + @protected + int dco_decode_u_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + void dco_decode_unit(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return; + } + + @protected + String sse_decode_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_list_prim_u_8_strict(deserializer); + return utf8.decoder.convert(inner); + } + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + int sse_decode_u_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8(); + } + + @protected + void sse_decode_unit(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + int sse_decode_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt32(); + } + + @protected + bool sse_decode_bool(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8() != 0; + } + + @protected + void sse_encode_String(String self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); + } + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer.putUint8List(self); + } + + @protected + void sse_encode_u_8(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self); + } + + @protected + void sse_encode_unit(void self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + void sse_encode_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt32(self); + } + + @protected + void sse_encode_bool(bool self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self ? 1 : 0); + } +} diff --git a/lib/src/rust/frb_generated.io.dart b/lib/src/rust/frb_generated.io.dart new file mode 100644 index 0000000..2af216f --- /dev/null +++ b/lib/src/rust/frb_generated.io.dart @@ -0,0 +1,84 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api/simple.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi' as ffi; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + String dco_decode_String(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) => + RustLibWire(lib.ffiDynamicLibrary); + + /// Holds the symbol lookup function. + final ffi.Pointer Function(String symbolName) + _lookup; + + /// The symbols are looked up in [dynamicLibrary]. + RustLibWire(ffi.DynamicLibrary dynamicLibrary) + : _lookup = dynamicLibrary.lookup; +} diff --git a/lib/src/rust/frb_generated.web.dart b/lib/src/rust/frb_generated.web.dart new file mode 100644 index 0000000..981737f --- /dev/null +++ b/lib/src/rust/frb_generated.web.dart @@ -0,0 +1,84 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +// Static analysis wrongly picks the IO variant, thus ignore this +// ignore_for_file: argument_type_not_assignable + +import 'api/simple.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + String dco_decode_String(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + RustLibWire.fromExternalLibrary(ExternalLibrary lib); +} + +@JS('wasm_bindgen') +external RustLibWasmModule get wasmModule; + +@JS() +@anonymous +extension type RustLibWasmModule._(JSObject _) implements JSObject {} diff --git a/lib/util/constants.dart b/lib/util/constants.dart index 67b6a00..9f46373 100644 --- a/lib/util/constants.dart +++ b/lib/util/constants.dart @@ -1 +1,21 @@ -const abawoServiceBtUUID = '0993826f-0ee4-4b37-9614-d13ecba4ffc2'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; + +const abawoServiceBtUUIDPrefix = '0993826f-0ee4-4b37-9614'; +const abawoUniversalShiftersServiceBtUUID = + '0993826f-0ee4-4b37-9614-d13ecba4ffc2'; + +bool isAbawoDeviceGuid(Uuid guid) { + return guid + .toString() + .toLowerCase() + .replaceAll('-', '') + .startsWith(abawoServiceBtUUIDPrefix.toLowerCase().replaceAll('-', '')); +} + +bool isAbawoUniversalShiftersDeviceGuid(Uuid guid) { + return guid == Uuid.parse(abawoUniversalShiftersServiceBtUUID); +} + +bool isConnectableAbawoDeviceGuid(Uuid guid) { + return isAbawoUniversalShiftersDeviceGuid(guid); +} diff --git a/lib/util/sharedPrefs.dart b/lib/util/sharedPrefs.dart index 02484f5..5edce16 100644 --- a/lib/util/sharedPrefs.dart +++ b/lib/util/sharedPrefs.dart @@ -1,8 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shared_preferences/shared_preferences.dart'; -part 'sharedPrefs.g.dart'; +// part 'sharedPrefs.g.dart'; final sharedPreferencesProvider = Provider((ref) => throw UnimplementedError()); diff --git a/lib/util/sharedPrefs.g.dart b/lib/util/sharedPrefs.g.dart deleted file mode 100644 index ff1f167..0000000 --- a/lib/util/sharedPrefs.g.dart +++ /dev/null @@ -1,197 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'sharedPrefs.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$sharedPrefValueHash() => r'6c78fac8d11d0df162d4d53f465c1c8535fcd150'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -abstract class _$SharedPrefValue extends BuildlessAutoDisposeNotifier { - late final String key; - late final T defaultValue; - - T build( - String key, - T defaultValue, - ); -} - -/// See also [SharedPrefValue]. -@ProviderFor(SharedPrefValue) -const sharedPrefValueProvider = SharedPrefValueFamily(); - -/// See also [SharedPrefValue]. -class SharedPrefValueFamily extends Family { - /// See also [SharedPrefValue]. - const SharedPrefValueFamily(); - - /// See also [SharedPrefValue]. - SharedPrefValueProvider call( - String key, - T defaultValue, - ) { - return SharedPrefValueProvider( - key, - defaultValue, - ); - } - - @override - SharedPrefValueProvider getProviderOverride( - covariant SharedPrefValueProvider provider, - ) { - return call( - provider.key, - provider.defaultValue, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'sharedPrefValueProvider'; -} - -/// See also [SharedPrefValue]. -class SharedPrefValueProvider - extends AutoDisposeNotifierProviderImpl { - /// See also [SharedPrefValue]. - SharedPrefValueProvider( - String key, - T defaultValue, - ) : this._internal( - () => SharedPrefValue() - ..key = key - ..defaultValue = defaultValue, - from: sharedPrefValueProvider, - name: r'sharedPrefValueProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$sharedPrefValueHash, - dependencies: SharedPrefValueFamily._dependencies, - allTransitiveDependencies: - SharedPrefValueFamily._allTransitiveDependencies, - key: key, - defaultValue: defaultValue, - ); - - SharedPrefValueProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.key, - required this.defaultValue, - }) : super.internal(); - - final String key; - final T defaultValue; - - @override - T runNotifierBuild( - covariant SharedPrefValue notifier, - ) { - return notifier.build( - key, - defaultValue, - ); - } - - @override - Override overrideWith(SharedPrefValue Function() create) { - return ProviderOverride( - origin: this, - override: SharedPrefValueProvider._internal( - () => create() - ..key = key - ..defaultValue = defaultValue, - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - key: key, - defaultValue: defaultValue, - ), - ); - } - - @override - AutoDisposeNotifierProviderElement createElement() { - return _SharedPrefValueProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is SharedPrefValueProvider && - other.key == key && - other.defaultValue == defaultValue; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, key.hashCode); - hash = _SystemHash.combine(hash, defaultValue.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin SharedPrefValueRef on AutoDisposeNotifierProviderRef { - /// The parameter `key` of this provider. - String get key; - - /// The parameter `defaultValue` of this provider. - T get defaultValue; -} - -class _SharedPrefValueProviderElement - extends AutoDisposeNotifierProviderElement - with SharedPrefValueRef { - _SharedPrefValueProviderElement(super.provider); - - @override - String get key => (origin as SharedPrefValueProvider).key; - @override - T get defaultValue => (origin as SharedPrefValueProvider).defaultValue; -} -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/widgets/bike_scan_dialog.dart b/lib/widgets/bike_scan_dialog.dart new file mode 100644 index 0000000..0c144d0 --- /dev/null +++ b/lib/widgets/bike_scan_dialog.dart @@ -0,0 +1,214 @@ +import 'package:abawo_bt_app/controller/bluetooth.dart'; +import 'package:abawo_bt_app/model/shifter_types.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class BikeScanDialog extends ConsumerStatefulWidget { + const BikeScanDialog({ + required this.excludedDeviceId, + super.key, + }); + + final String excludedDeviceId; + + static Future show( + BuildContext context, { + required String excludedDeviceId, + }) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (_) => BikeScanDialog(excludedDeviceId: excludedDeviceId), + ); + } + + @override + ConsumerState createState() => _BikeScanDialogState(); +} + +class _BikeScanDialogState extends ConsumerState { + bool _showAll = false; + BluetoothController? _controller; + + @override + void initState() { + super.initState(); + _startScan(); + } + + Future _startScan() async { + final controller = await ref.read(bluetoothProvider.future); + _controller = controller; + await controller.stopScan(); + await controller.startScan(); + } + + @override + void dispose() { + _controller?.stopScan(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final btAsync = ref.watch(bluetoothProvider); + + return Dialog( + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: SizedBox( + width: 520, + height: 520, + child: btAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => Center(child: Text('Bluetooth error: $err')), + data: (controller) { + _controller ??= controller; + return Column( + children: [ + _buildHeader(context), + const Divider(height: 1), + Expanded( + child: StreamBuilder>( + stream: controller.scanResultsStream, + initialData: controller.scanResults, + builder: (context, snapshot) { + final devices = + _filteredDevices(snapshot.data ?? const []); + if (devices.isEmpty) { + return const Center( + child: Text('No matching devices nearby.'), + ); + } + + return ListView.separated( + itemCount: devices.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final device = devices[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + leading: CircleAvatar( + backgroundColor: Theme.of(context) + .colorScheme + .primaryContainer, + child: const Icon(Icons.pedal_bike), + ), + title: Text( + device.name.isEmpty + ? 'Unknown Device' + : device.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + device.id, + style: const TextStyle(fontFamily: 'monospace'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: _RssiBadge(rssi: device.rssi), + onTap: () { + Navigator.of(context).pop(device); + }, + ); + }, + ); + }, + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), + child: Row( + children: [ + const Expanded( + child: Text( + 'Select Bike', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + ), + ), + Row( + children: [ + const Text('Show All'), + Switch( + value: _showAll, + onChanged: (value) { + setState(() { + _showAll = value; + }); + }, + ), + ], + ), + IconButton( + tooltip: 'Rescan', + onPressed: _startScan, + icon: const Icon(Icons.refresh), + ), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + ); + } + + List _filteredDevices(List devices) { + final ftmsUuid = Uuid.parse(ftmsServiceUuid); + return devices.where((device) { + if (device.id == widget.excludedDeviceId) { + return false; + } + if (_showAll) { + return true; + } + return device.serviceUuids.contains(ftmsUuid); + }).toList(growable: false); + } +} + +class _RssiBadge extends StatelessWidget { + const _RssiBadge({required this.rssi}); + + final int rssi; + + @override + Widget build(BuildContext context) { + final color = rssi > -65 + ? Colors.green + : rssi > -80 + ? Colors.orange + : Colors.red; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$rssi dBm', + style: TextStyle( + color: color, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ); + } +} diff --git a/lib/widgets/device_listitem.dart b/lib/widgets/device_listitem.dart index e199b40..d847ecf 100644 --- a/lib/widgets/device_listitem.dart +++ b/lib/widgets/device_listitem.dart @@ -1,18 +1,23 @@ +import 'package:abawo_bt_app/model/bluetooth_device_model.dart'; import 'package:flutter/material.dart'; import 'dart:ui'; // Required for ImageFilter class DeviceListItem extends StatelessWidget { final String deviceName; final String deviceId; // Added for potential future use or subtitle - final bool isUnknownDevice; + final DeviceType type; // final String? imageUrl; // Optional image URL - commented out for now + final bool isConnecting; // Add this line + final Widget? trailing; const DeviceListItem({ super.key, required this.deviceName, required this.deviceId, - this.isUnknownDevice = false, + required this.type, // this.imageUrl, + this.isConnecting = false, // Add this line + this.trailing, }); @override @@ -22,11 +27,11 @@ class DeviceListItem extends StatelessWidget { // Glassy effect colors - adjust transparency and base color as needed final glassColor = isDarkMode - ? Colors.white.withOpacity(0.1) - : Colors.black.withOpacity(0.05); + ? Colors.white.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.05); final shadowColor = isDarkMode - ? Colors.black.withOpacity(0.4) - : Colors.grey.withOpacity(0.5); + ? Colors.black.withValues(alpha: 0.4) + : Colors.grey.withValues(alpha: 0.5); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), @@ -57,21 +62,33 @@ class DeviceListItem extends StatelessWidget { glassColor, // Semi-transparent color for glass effect borderRadius: BorderRadius.circular(12), border: Border.all( - color: Colors.white.withOpacity(0.2), // Subtle border + color: + Colors.white.withValues(alpha: 0.2), // Subtle border width: 0.5, ), ), - child: const Center( - // Placeholder '?' - replace with Image widget when imageUrl is available - child: Text( - '?', - style: TextStyle( - fontSize: 30, - fontWeight: FontWeight.bold, - color: Colors.white70, // Adjust color as needed - ), - ), - ), + child: type == DeviceType.universalShifters + // For Universal Shifters: Image fills the container, constrained by rounded borders + ? ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.asset( + 'assets/images/shifter-wireframe.png', + fit: BoxFit.cover, // Cover the entire container + width: 60, + height: 60, + ), + ) + // For other devices: Question mark with padding + : const Center( + child: Text( + '?', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + color: Colors.white70, + ), + ), + ), ), ), ), @@ -84,14 +101,16 @@ class DeviceListItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - isUnknownDevice ? 'Unknown Device' : deviceName, + deviceName.isEmpty ? 'Unknown Device' : deviceName, style: TextStyle( fontSize: 16, - fontWeight: - isUnknownDevice ? FontWeight.normal : FontWeight.w500, - fontStyle: - isUnknownDevice ? FontStyle.italic : FontStyle.normal, - color: isUnknownDevice + fontWeight: deviceName.isEmpty + ? FontWeight.normal + : FontWeight.w500, + fontStyle: deviceName.isEmpty + ? FontStyle.italic + : FontStyle.normal, + color: deviceName.isEmpty ? theme.hintColor : theme.textTheme.bodyLarge?.color, ), @@ -108,6 +127,19 @@ class DeviceListItem extends StatelessWidget { ], ), ), + // Add spinner if connecting (Add this block) + if (isConnecting) + Padding( + padding: const EdgeInsets.only(left: 12.0), // Add some spacing + child: SizedBox( + width: 20, // Define spinner size + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.0), // Use a small spinner + ), + ) + else if (trailing != null) + trailing!, // Optional: Add an icon or button on the far right if needed later // Icon(Icons.chevron_right, color: theme.hintColor), ], diff --git a/lib/widgets/gear_ratio_editor_card.dart b/lib/widgets/gear_ratio_editor_card.dart new file mode 100644 index 0000000..49ebc47 --- /dev/null +++ b/lib/widgets/gear_ratio_editor_card.dart @@ -0,0 +1,892 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +class GearRatioPreset { + const GearRatioPreset({ + required this.name, + required this.description, + required this.ratios, + }); + + final String name; + final String description; + final List ratios; +} + +class GearRatioEditorCard extends StatefulWidget { + const GearRatioEditorCard({ + required this.ratios, + required this.isLoading, + required this.onSave, + required this.presets, + this.errorText, + this.onRetry, + super.key, + }); + + final List ratios; + final bool isLoading; + final Future Function(List ratios) onSave; + final List presets; + final String? errorText; + final VoidCallback? onRetry; + + @override + State createState() => _GearRatioEditorCardState(); +} + +class _GearRatioEditorCardState extends State { + static const double _sliderMin = 0.10; + static const double _sliderMax = 3.90; + static const double _sliderPivotT = 0.50; + static const double _sliderPivotV = 1.00; + static const Duration _animDuration = Duration(milliseconds: 280); + static const Curve _animCurve = Cubic(0.2, 0.8, 0.2, 1.0); + + bool _isExpanded = false; + bool _isEditing = false; + bool _sortAscending = true; + bool _isSaving = false; + double _stretchFactor = 1.0; + List? _stretchBase; + int _gearLayoutVersion = 0; + + List _committed = const []; + List _draft = const []; + + @override + void initState() { + super.initState(); + _committed = List.from(widget.ratios); + _draft = List.from(widget.ratios); + } + + @override + void didUpdateWidget(covariant GearRatioEditorCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (!_isEditing && !_listEquals(oldWidget.ratios, widget.ratios)) { + _committed = List.from(widget.ratios); + _draft = List.from(widget.ratios); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: + theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: AnimatedSize( + duration: _animDuration, + curve: _animCurve, + alignment: Alignment.topCenter, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 10, 8), + child: Row( + children: [ + const Expanded( + child: Text( + 'Gear Ratios', + style: + TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + ), + if (_isEditing) ...[ + TextButton( + onPressed: _isSaving ? null : _onCancel, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _isSaving ? null : _onSave, + child: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ] else + IconButton( + tooltip: 'Edit ratios', + onPressed: (widget.isLoading || widget.errorText != null) + ? null + : _enterEditMode, + icon: const Icon(Icons.edit_outlined), + ), + ], + ), + ), + if (widget.isLoading) + const Padding( + padding: EdgeInsets.fromLTRB(16, 20, 16, 24), + child: Center(child: CircularProgressIndicator()), + ) + else if (widget.errorText != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.errorText!, + style: TextStyle(color: theme.colorScheme.error), + ), + if (widget.onRetry != null) + TextButton.icon( + onPressed: widget.onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ) + else if (_committed.isEmpty && !_isEditing) + const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 18), + child: Text('No gear ratios found on device.'), + ) + else ...[ + if ((_isEditing ? _draft : _committed).isNotEmpty) + InkWell( + borderRadius: BorderRadius.circular(16), + onTap: _isEditing + ? null + : () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 2, 14, 8), + child: AnimatedContainer( + duration: _animDuration, + curve: _animCurve, + height: _isExpanded ? 210 : 130, + child: _GearRatioGraph( + ratios: _isEditing ? _draft : _committed, + compact: !_isExpanded, + ), + ), + ), + ), + if (!_isExpanded && !_isEditing && _committed.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 10), + child: _compactRatioStrip(context, _committed), + ), + AnimatedSize( + duration: _animDuration, + curve: _animCurve, + alignment: Alignment.topCenter, + child: _isExpanded && !_isEditing + ? Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 12), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < _committed.length; i++) + _ratioChip(context, i + 1, _committed[i]), + ], + ), + ) + : const SizedBox.shrink(), + ), + if (_isEditing) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), + child: Row( + children: [ + const Text('Sort ascending'), + Switch( + value: _sortAscending, + onChanged: (value) { + setState(() { + _sortAscending = value; + if (_sortAscending) { + _sortDraft(animate: true); + } + }); + }, + ), + const Spacer(), + OutlinedButton.icon( + onPressed: _openPresetPicker, + icon: const Icon(Icons.tune), + label: const Text('Load preset'), + ), + ], + ), + ), + if (_draft.isEmpty) + const Padding( + padding: EdgeInsets.fromLTRB(14, 0, 14, 10), + child: + Text('No ratios yet. Load a preset to start editing.'), + ), + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Stretch all: ${_stretchFactor.toStringAsFixed(2)}x', + style: theme.textTheme.bodyMedium, + ), + Slider( + min: 0.0, + max: 1.0, + value: _stretchToSlider(_stretchFactor), + onChangeStart: (_) { + _stretchBase = List.from(_draft); + }, + onChanged: (value) { + final factor = _sliderToStretch(value); + final base = _stretchBase ?? _draft; + setState(() { + _stretchFactor = factor; + _draft = base + .map((ratio) => _quantizeRatio(ratio * factor)) + .toList(growable: false); + }); + }, + onChangeEnd: (_) { + setState(() { + if (_sortAscending) { + _sortDraft(animate: true); + } + _stretchBase = null; + }); + }, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 12), + child: AnimatedSwitcher( + duration: _animDuration, + switchInCurve: _animCurve, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: _snappyTransition, + child: Wrap( + key: ValueKey('editors-$_gearLayoutVersion'), + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < _draft.length; i++) + KeyedSubtree( + key: ValueKey('editor-${i + 1}'), + child: _buildGearEditor(context, i), + ), + ], + ), + ), + ), + ], + ], + ], + ), + ), + ); + } + + Widget _snappyTransition(Widget child, Animation animation) { + final curved = CurvedAnimation(parent: animation, curve: _animCurve); + return FadeTransition( + opacity: curved, + child: SizeTransition( + sizeFactor: curved, + axisAlignment: -1, + child: child, + ), + ); + } + + Widget _buildGearEditor(BuildContext context, int index) { + final ratio = _draft[index]; + final sliderValue = _valueToSlider(ratio); + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: 230, maxWidth: 280), + child: Container( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.7), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outlineVariant + .withValues(alpha: 0.6), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Gear ${index + 1}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const Spacer(), + InkWell( + borderRadius: BorderRadius.circular(6), + onTap: () => _editRatioText(index), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + ratio.toStringAsFixed(2), + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + Slider( + min: 0, + max: 1, + value: sliderValue, + onChanged: (value) { + setState(() { + _draft[index] = _quantizeRatio(_sliderToValue(value)); + }); + }, + onChangeEnd: (_) { + setState(() { + if (_sortAscending) { + _sortDraft(animate: true); + } + }); + }, + ), + ], + ), + ), + ); + } + + Future _editRatioText(int index) async { + final controller = TextEditingController( + text: _draft[index].toStringAsFixed(2), + ); + + final value = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Set gear ${index + 1} ratio'), + content: TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + autofocus: true, + decoration: const InputDecoration(hintText: 'e.g. 1.25'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final parsed = double.tryParse(controller.text.trim()); + Navigator.of(context).pop(parsed); + }, + child: const Text('Set'), + ), + ], + ); + }, + ); + + if (!mounted || value == null) { + return; + } + + setState(() { + _draft[index] = _quantizeRatio(value); + if (_sortAscending) { + _sortDraft(animate: true); + } + }); + } + + Future _openPresetPicker() async { + final selected = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 12), + child: ListView.separated( + itemCount: widget.presets.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final preset = widget.presets[index]; + return Material( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(14), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => Navigator.of(context).pop(preset), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + preset.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + preset.description, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 10), + SizedBox( + height: 90, + child: _GearRatioGraph( + ratios: preset.ratios, compact: true), + ), + const SizedBox(height: 8), + _compactRatioStrip( + context, + preset.ratios, + showGearLabel: false, + ), + ], + ), + ), + ), + ); + }, + ), + ); + }, + ); + + if (!mounted || selected == null) { + return; + } + + setState(() { + _draft = selected.ratios.map(_quantizeRatio).toList(growable: false); + if (_sortAscending) { + _sortDraft(animate: true); + } + }); + } + + void _sortDraft({bool animate = false}) { + final sorted = _sorted(_draft); + if (animate && !_listEquals(_draft, sorted)) { + _gearLayoutVersion++; + } + _draft = sorted; + } + + void _enterEditMode() { + setState(() { + _isEditing = true; + _isExpanded = true; + _stretchFactor = 1.0; + _stretchBase = null; + _draft = List.from(_committed); + }); + } + + void _onCancel() { + setState(() { + _isEditing = false; + _draft = List.from(_committed); + _stretchFactor = 1.0; + _stretchBase = null; + }); + } + + Future _onSave() async { + setState(() { + _isSaving = true; + }); + + final message = await widget.onSave(List.from(_draft)); + if (!mounted) { + return; + } + + setState(() { + _isSaving = false; + if (message == null) { + _committed = List.from(_draft); + _isEditing = false; + } + }); + + if (message != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } + + Widget _ratioChip(BuildContext context, int gear, double ratio) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: theme.colorScheme.surface.withValues(alpha: 0.7), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: Text('G$gear ${ratio.toStringAsFixed(2)}'), + ); + } + + Widget _compactRatioStrip( + BuildContext context, + List ratios, { + bool showGearLabel = true, + }) { + final theme = Theme.of(context); + return SizedBox( + height: 26, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var i = 0; i < ratios.length; i++) + Padding( + padding: EdgeInsets.only(right: i == ratios.length - 1 ? 0 : 6), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: theme.colorScheme.surface.withValues(alpha: 0.7), + border: Border.all( + color: theme.colorScheme.outlineVariant + .withValues(alpha: 0.55), + ), + ), + child: Text( + showGearLabel + ? 'G${i + 1} ${ratios[i].toStringAsFixed(2)}' + : ratios[i].toStringAsFixed(2), + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ); + } + + double _quantizeRatio(double raw) { + final clamped = raw.clamp(_sliderMin, _sliderMax); + return ((clamped * 64).round() / 64.0).clamp(_sliderMin, _sliderMax); + } + + List _sorted(List values) { + final out = List.from(values)..sort(); + return out; + } + + bool _listEquals(List a, List b) { + if (a.length != b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + return false; + } + } + return true; + } + + double _sliderToValue(double t) { + final normalized = t.clamp(0.0, 1.0); + if (normalized <= _sliderPivotT) { + final u = normalized / _sliderPivotT; + return _sliderMin * math.pow(_sliderPivotV / _sliderMin, u); + } + final u = (normalized - _sliderPivotT) / (1 - _sliderPivotT); + return _sliderPivotV * math.pow(_sliderMax / _sliderPivotV, u); + } + + double _valueToSlider(double value) { + final clamped = value.clamp(_sliderMin, _sliderMax); + if (clamped <= _sliderPivotV) { + final u = + math.log(clamped / _sliderMin) / math.log(_sliderPivotV / _sliderMin); + return (u * _sliderPivotT).clamp(0.0, 1.0); + } + final u = math.log(clamped / _sliderPivotV) / + math.log(_sliderMax / _sliderPivotV); + return (_sliderPivotT + u * (1 - _sliderPivotT)).clamp(0.0, 1.0); + } + + double _sliderToStretch(double t) { + return (0.6 + (1.6 - 0.6) * t).clamp(0.6, 1.6); + } + + double _stretchToSlider(double factor) { + return ((factor - 0.6) / (1.6 - 0.6)).clamp(0.0, 1.0); + } +} + +class _GearRatioGraph extends StatelessWidget { + const _GearRatioGraph({required this.ratios, required this.compact}); + + final List ratios; + final bool compact; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.65), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!compact) + Text( + 'Input RPM -> Output RPM', + style: textTheme.bodySmall, + ), + Expanded( + child: CustomPaint( + painter: _GearRatioGraphPainter( + ratios: ratios, + axisColor: Theme.of(context).colorScheme.outline, + lineColor: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onSurface, + compact: compact, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _GearRatioGraphPainter extends CustomPainter { + const _GearRatioGraphPainter({ + required this.ratios, + required this.axisColor, + required this.lineColor, + required this.textColor, + required this.compact, + }); + + final List ratios; + final Color axisColor; + final Color lineColor; + final Color textColor; + final bool compact; + + @override + void paint(Canvas canvas, Size size) { + if (ratios.isEmpty) { + return; + } + + const left = 28.0; + const right = 10.0; + const top = 8.0; + const bottom = 24.0; + final chart = Rect.fromLTWH( + left, + top, + size.width - left - right, + size.height - top - bottom, + ); + + final axisPaint = Paint() + ..color = axisColor.withValues(alpha: 0.55) + ..strokeWidth = 1.2; + canvas.drawRect(chart, axisPaint..style = PaintingStyle.stroke); + + final maxRatio = ratios.reduce(math.max); + final xMax = 120.0; + final computedYMax = (xMax * math.max(maxRatio, 1.0)).toDouble(); + final yMax = math.min(400.0, computedYMax); + + for (var i = 1; i <= 3; i++) { + final y = chart.bottom - (chart.height * (i / 4)); + canvas.drawLine( + Offset(chart.left, y), + Offset(chart.right, y), + axisPaint..color = axisColor.withValues(alpha: 0.2), + ); + } + + for (var i = 1; i <= 3; i++) { + final x = chart.left + (chart.width * (i / 4)); + canvas.drawLine( + Offset(x, chart.top), + Offset(x, chart.bottom), + axisPaint..color = axisColor.withValues(alpha: 0.15), + ); + } + + for (var i = 0; i < ratios.length; i++) { + final ratio = ratios[i]; + final p = i / math.max(1, ratios.length - 1); + final color = Color.lerp( + lineColor.withValues(alpha: 0.40), + lineColor, + p, + )!; + + final endYValue = xMax * ratio; + final isClipped = endYValue > yMax; + final endX = isClipped + ? chart.left + chart.width * (yMax / endYValue) + : chart.right; + final endY = isClipped + ? chart.top + : chart.bottom - (endYValue / yMax) * chart.height; + + final linePaint = Paint() + ..color = color + ..strokeWidth = 2; + + canvas.drawLine( + Offset(chart.left, chart.bottom), + Offset(endX, endY), + linePaint, + ); + + if (!compact && i % 2 == 0) { + final tp = TextPainter( + text: TextSpan( + text: 'G${i + 1}', + style: TextStyle( + fontSize: 10, color: textColor.withValues(alpha: 0.75)), + ), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint( + canvas, + Offset( + (endX - tp.width - 2).clamp(chart.left, chart.right - tp.width), + (endY - 10).clamp(chart.top, chart.bottom - tp.height), + ), + ); + } + } + + final xLabel = TextPainter( + text: TextSpan( + text: 'In RPM', + style: + TextStyle(fontSize: 10, color: textColor.withValues(alpha: 0.75)), + ), + textDirection: TextDirection.ltr, + )..layout(); + xLabel.paint(canvas, Offset(chart.right - xLabel.width, chart.bottom + 17)); + + final xMinValue = TextPainter( + text: TextSpan( + text: '0', + style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)), + ), + textDirection: TextDirection.ltr, + )..layout(); + xMinValue.paint(canvas, Offset(chart.left, chart.bottom + 5)); + + final xMaxValue = TextPainter( + text: TextSpan( + text: xMax.toStringAsFixed(0), + style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)), + ), + textDirection: TextDirection.ltr, + )..layout(); + xMaxValue.paint( + canvas, + Offset(chart.right - xMaxValue.width, chart.bottom + 5), + ); + + final yLabel = TextPainter( + text: TextSpan( + text: 'Out RPM', + style: + TextStyle(fontSize: 10, color: textColor.withValues(alpha: 0.75)), + ), + textDirection: TextDirection.ltr, + )..layout(); + yLabel.paint(canvas, Offset(chart.left - 28, chart.top - 14)); + + final yMinValue = TextPainter( + text: TextSpan( + text: '0', + style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)), + ), + textDirection: TextDirection.ltr, + )..layout(); + yMinValue.paint( + canvas, + Offset(chart.left - yMinValue.width - 4, chart.bottom - yMinValue.height), + ); + + final yMaxValue = TextPainter( + text: TextSpan( + text: yMax.toStringAsFixed(0), + style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)), + ), + textDirection: TextDirection.ltr, + )..layout(); + yMaxValue.paint( + canvas, + Offset(chart.left - yMaxValue.width - 4, chart.top), + ); + } + + @override + bool shouldRepaint(covariant _GearRatioGraphPainter oldDelegate) { + if (oldDelegate.compact != compact || + oldDelegate.ratios.length != ratios.length) { + return true; + } + for (var i = 0; i < ratios.length; i++) { + if (ratios[i] != oldDelegate.ratios[i]) { + return true; + } + } + return false; + } +} diff --git a/lib/widgets/horizontal_scanning_animation.dart b/lib/widgets/horizontal_scanning_animation.dart new file mode 100644 index 0000000..23de97e --- /dev/null +++ b/lib/widgets/horizontal_scanning_animation.dart @@ -0,0 +1,207 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +class HorizontalScanningAnimation extends StatefulWidget { + final bool isScanning; // Add this to control the animation + final Color waveColor; + final double height; + const HorizontalScanningAnimation({ + super.key, + required this.isScanning, // Make it required + this.waveColor = Colors.lightBlueAccent, + this.height = 50.0, + }); + @override + _HorizontalScanningAnimationState createState() => + _HorizontalScanningAnimationState(); +} + +class _HorizontalScanningAnimationState + extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + // Start repeating only if initially scanning + if (widget.isScanning) { + _controller.repeat(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant HorizontalScanningAnimation oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isScanning != oldWidget.isScanning) { + if (widget.isScanning) { + // Start or resume repeating + if (!_controller.isAnimating) { + // If stopped previously, reset before repeating for a clean start + // Though, repeat() should handle restarting if stopped. Testing needed. + // _controller.reset(); // Optional: uncomment if repeat doesn't restart smoothly + _controller.repeat(); + } + } else { + // Stop repeating, but let the current animation cycle finish visually + if (_controller.isAnimating) { + _controller.stop( + canceled: + false); // Use canceled: false to let it finish the current tick + } + } + } + } + + @override + Widget build(BuildContext context) { + // Only build the painter if the controller is active or was recently stopped + // This prevents drawing when completely idle. Check if value is changing or non-zero. + // Or simply rely on the AnimatedBuilder which won't rebuild if controller is idle at 0.0 + return SizedBox( + height: widget.height, + width: double.infinity, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return CustomPaint( + painter: _HorizontalWavePainter( + progress: _controller.value, + waveColor: widget.waveColor, + ), + ); + }, + ), + ); + } +} + +class _HorizontalWavePainter extends CustomPainter { + final double progress; // Animation value from 0.0 to 1.0 + final Color waveColor; + final int waveCount = 2; // Number of waves visible at once + final double waveAmplitude = 10.0; // Max height deviation of the wave + + _HorizontalWavePainter({required this.progress, required this.waveColor}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = waveColor.withValues(alpha: 0.6) // Semi-transparent waves + ..style = PaintingStyle.fill; + + final centerY = size.height / 2; + final width = size.width; + + // Draw multiple waves propagating outwards + for (int i = 0; i < waveCount; i++) { + // Calculate the phase offset for each wave based on progress and index + // This creates the effect of waves moving outwards + double waveProgress = (progress + i / waveCount) % 1.0; + + // Use an easing curve for smoother expansion + double easedProgress = Curves.easeInOutSine.transform(waveProgress); + + // Calculate the current horizontal position (expanding from center) + // The wave starts narrow and expands outwards + double currentWidth = + width * easedProgress * 0.8; // Max 80% width expansion + double startX = (width / 2) - (currentWidth / 2); + double endX = (width / 2) + (currentWidth / 2); + + // Calculate opacity based on progress (fade in and out) + double opacity; + if (waveProgress < 0.1) { + opacity = waveProgress / 0.1; // Fade in + } else if (waveProgress > 0.8) { + opacity = (1.0 - waveProgress) / 0.2; // Fade out + } else { + opacity = 1.0; + } + opacity = max(0.0, opacity); // Clamp opacity + + if (opacity <= 0.0 || currentWidth < 5) + continue; // Skip drawing if invisible or too small + + // Create the wave path + final path = Path(); + path.moveTo(startX, centerY); + + // Calculate points for the sine wave shape within the current width + const int segments = 50; // Number of segments for the curve + for (int j = 0; j <= segments; j++) { + double segmentProgress = j / segments; + double x = startX + currentWidth * segmentProgress; + // Apply sine wave based on segment progress and overall animation progress + // Multiply by (1 - easedProgress) to reduce amplitude as it expands + double yOffset = waveAmplitude * + sin(segmentProgress * 2 * pi + progress * 4 * pi) * + (1 - easedProgress * 0.8) * // Reduce amplitude as it expands + opacity; // Apply opacity effect to amplitude too + path.lineTo(x, centerY + yOffset); + } + + // Draw a filled shape (like a lens flare or horizontal bar) + // Adjust thickness based on easedProgress (thicker in the middle, thinner at ends) + double thickness = + waveAmplitude * (1 - easedProgress * 0.9) * opacity * 0.5; + paint.color = waveColor.withValues( + alpha: opacity * 0.5); // Update paint color with opacity + + // Simplified: Draw a rectangle that pulses + // More complex shapes could be drawn here using path.arcTo or path.quadraticBezierTo + // For simplicity, let's use a slightly blurred rectangle effect + + final rectPath = Path() + ..addRRect(RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(width / 2, centerY), + width: currentWidth, + height: thickness * 2), + Radius.circular(thickness))); + + // Apply a blur effect + final blurPaint = Paint() + ..color = waveColor.withValues(alpha: opacity * 0.4) + ..maskFilter = MaskFilter.blur( + BlurStyle.normal, thickness * 1.5); // Blur based on thickness + + // Draw the blurred shape + canvas.drawPath(rectPath, blurPaint); + + // Draw a slightly smaller, less opaque shape on top for highlight + final highlightPaint = Paint() + ..color = waveColor.withValues(alpha: opacity * 0.7) + ..style = PaintingStyle.fill; + final highlightRectPath = Path() + ..addRRect(RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(width / 2, centerY), + width: currentWidth * 0.95, + height: thickness * 1.5), + Radius.circular(thickness * 0.8))); + canvas.drawPath(highlightRectPath, highlightPaint); + + // Old Path drawing - keep if rectangle isn't desired + // paint.color = waveColor.withValues(alpha: opacity * 0.5); // Apply opacity + // canvas.drawPath(path, paint); + } + } + + @override + bool shouldRepaint(covariant _HorizontalWavePainter oldDelegate) { + // Repaint whenever the animation progress or color changes + return oldDelegate.progress != progress || + oldDelegate.waveColor != waveColor; + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..c46ca33 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,14 @@ #include "generated_plugin_registrant.h" +#include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) nb_utils_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "NbUtilsPlugin"); + nb_utils_plugin_register_with_registrar(nb_utils_registrar); + g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); + sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..fbed341 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,9 +3,12 @@ # list(APPEND FLUTTER_PLUGIN_LIST + nb_utils + sqlite3_flutter_libs ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + rust_lib_abawo_bt_app ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a12b734..080d439 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,20 @@ import FlutterMacOS import Foundation +import connectivity_plus import flutter_blue_plus_darwin +import nb_utils +import path_provider_foundation +import reactive_ble_mobile import shared_preferences_foundation +import sqlite3_flutter_libs func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) + NbUtilsPlugin.register(with: registry.registrar(forPlugin: "NbUtilsPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + ReactiveBlePlugin.register(with: registry.registrar(forPlugin: "ReactiveBlePlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 4acd39e..c34064e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.2" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" build_config: dependency: transitive description: @@ -129,14 +137,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.9.5" + cbor: + dependency: "direct main" + description: + name: cbor + sha256: "2c5c37650f0a2d25149f03e748ab7b2857787bde338f95fe947738b80d713da2" + url: "https://pub.dev" + source: hosted + version: "6.5.1" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -165,10 +189,10 @@ packages: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" code_builder: dependency: transitive description: @@ -181,10 +205,26 @@ packages: dependency: transitive description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.19.1" + connectivity_plus: + dependency: transitive + description: + name: connectivity_plus + sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" convert: dependency: transitive description: @@ -257,14 +297,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + drift: + dependency: "direct main" + description: + name: drift + sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5" + url: "https://pub.dev" + source: hosted + version: "2.26.0" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "0d3f8b33b76cf1c6a82ee34d9511c40957549c4674b8f1688609e6d6c7306588" + url: "https://pub.dev" + source: hosted + version: "2.26.0" + drift_flutter: + dependency: "direct main" + description: + name: drift_flutter + sha256: "0cadbf3b8733409a6cf61d18ba2e94e149df81df7de26f48ae0695b48fd71922" + url: "https://pub.dev" + source: hosted + version: "0.2.4" fake_async: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" ffi: dependency: transitive description: @@ -298,50 +362,63 @@ packages: dependency: "direct main" description: name: flutter_blue_plus - sha256: "2d926dbef0fd6c58d4be8fca9eaaf1ba747c0ccb8373ddd5386665317e26eb61" + sha256: "399b3dbc15562ef59749f04e43a99ccbb91540022380d5f269aff3c2787534e4" url: "https://pub.dev" source: hosted - version: "1.35.3" + version: "2.1.0" flutter_blue_plus_android: dependency: transitive description: name: flutter_blue_plus_android - sha256: c1d83f84b514e46345a8a58599c428f20b11e78379521e0d3b0611c7b7cbf2c1 + sha256: "5010b0960cce533a8fa71401573f044362c3e2e111dc6eb4898c92e85f85f50c" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "8.1.0" flutter_blue_plus_darwin: dependency: transitive description: name: flutter_blue_plus_darwin - sha256: "8d0a0f11f83b13dda173396b7e4028b4e8656bc8dbbc82c26a7e49aafc62644b" + sha256: d160a8128e3a016fa58dd65ab6dac05cbc73e0fa799a1f24211d041641ed63ba url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "8.1.0" flutter_blue_plus_linux: dependency: transitive description: name: flutter_blue_plus_linux - sha256: "1d367ed378b2bd6c3b9685fda7044e1d2f169884802b7dec7badb31a99a72660" + sha256: f5b02244d89465ba82c8c512686c66362fbb01f52fa03d645ed353ebf3883242 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "8.1.0" flutter_blue_plus_platform_interface: dependency: transitive description: name: flutter_blue_plus_platform_interface - sha256: "114f8e85a03a28a48d707a4df6cc9218e1f2005cf260c5e815e5585a00da5778" + sha256: "6e0fc04b77491dbfdbcd46c1a021b12f2f5fc5d6e01777f93a38a8431989b7f0" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "8.1.0" flutter_blue_plus_web: dependency: transitive description: name: flutter_blue_plus_web - sha256: db70cdc41bc743763dc0d47e8c7c10f3923cbbe71b33d9dc21deea482affeb4d + sha256: "376aad9595ee389c7cd56e0c373e78abcaa790c821ece9cb81f0969ec94c5bca" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "8.1.0" + flutter_blue_plus_winrt: + dependency: transitive + description: + name: flutter_blue_plus_winrt + sha256: "34be2d8e23d5881b46accebb0e71025f7d52869d72ea98b5082c20764e06aa80" + url: "https://pub.dev" + source: hosted + version: "0.0.16" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -350,6 +427,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_reactive_ble: + dependency: "direct main" + description: + name: flutter_reactive_ble + sha256: "3ca8430bc7a6cabe5529aab4afaa2e3cb285941f7f7ab7472604074e347c1302" + url: "https://pub.dev" + source: hosted + version: "5.4.0" flutter_riverpod: dependency: "direct main" description: @@ -358,6 +443,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_rust_bridge: + dependency: "direct main" + description: + name: flutter_rust_bridge + sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" + url: "https://pub.dev" + source: hosted + version: "2.11.1" flutter_test: dependency: "direct dev" description: flutter @@ -368,6 +461,14 @@ packages: description: flutter source: sdk version: "0.0.0" + fluttertoast: + dependency: transitive + description: + name: fluttertoast + sha256: "144ddd74d49c865eba47abe31cbc746c7b311c82d6c32e571fd73c4264b740e2" + url: "https://pub.dev" + source: hosted + version: "9.0.0" freezed: dependency: "direct dev" description: @@ -392,6 +493,19 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + functional_data: + dependency: transitive + description: + name: functional_data + sha256: "76d17dc707c40e552014f5a49c0afcc3f1e3f05e800cd6b7872940bfe41a5039" + url: "https://pub.dev" + source: hosted + version: "1.2.0" glob: dependency: transitive description: @@ -416,6 +530,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" hotreloader: dependency: transitive description: @@ -428,10 +550,10 @@ packages: dependency: transitive description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -448,6 +570,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" io: dependency: transitive description: @@ -484,26 +611,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.7" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -524,26 +651,26 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.17.0" mime: dependency: transitive description: @@ -552,6 +679,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + nb_utils: + dependency: "direct main" + description: + name: nb_utils + sha256: "2cfecbe67bc09ab31069cfe68e4eb232bc71f0670c98a91d0e73e7741a4798ed" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" package_config: dependency: transitive description: @@ -564,10 +707,34 @@ packages: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" + url: "https://pub.dev" + source: hosted + version: "2.2.16" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -624,6 +791,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "01dd9bd0fa02548bf2ceee13545d4a0ec6046459d847b6b061d8a27237108a08" + url: "https://pub.dev" + source: hosted + version: "2.1.0" pub_semver: dependency: transitive description: @@ -640,6 +823,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + reactive_ble_mobile: + dependency: transitive + description: + name: reactive_ble_mobile + sha256: "78af92cfb184770a277a8bdaa76b396a28dbada8931f3d8d0ff552414681f5db" + url: "https://pub.dev" + source: hosted + version: "5.4.0" + reactive_ble_platform_interface: + dependency: transitive + description: + name: reactive_ble_platform_interface + sha256: d0e6f86c7ee3865b74d8a7b6deb1fb8320b24176dfd9a3e475f84b7117eb42c7 + url: "https://pub.dev" + source: hosted + version: "5.4.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" riverpod: dependency: transitive description: @@ -688,8 +895,15 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + rust_lib_abawo_bt_app: + dependency: "direct main" + description: + path: rust_builder + relative: true + source: path + version: "0.0.1" rxdart: - dependency: transitive + dependency: "direct main" description: name: rxdart sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" @@ -700,10 +914,10 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "846849e3e9b68f3ef4b60c60cf4b3e02e9321bc7f4d8c4692cf87ffa82fc8a3a" + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "2.5.4" shared_preferences_android: dependency: transitive description: @@ -805,14 +1019,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e" + url: "https://pub.dev" + source: hosted + version: "2.7.5" + sqlite3_flutter_libs: + dependency: transitive + description: + name: sqlite3_flutter_libs + sha256: "1a96b59227828d9eb1463191d684b37a27d66ee5ed7597fcf42eee6452c88a14" + url: "https://pub.dev" + source: hosted + version: "0.5.32" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "27dd0a9f0c02e22ac0eb42a23df9ea079ce69b52bb4a3b478d64e0ef34a263ee" + url: "https://pub.dev" + source: hosted + version: "0.41.0" stack_trace: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.12.1" state_notifier: dependency: transitive description: @@ -825,10 +1063,10 @@ packages: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: @@ -845,6 +1083,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -857,10 +1103,10 @@ packages: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.9" timing: dependency: transitive description: @@ -889,10 +1135,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -933,6 +1179,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" xdg_directories: dependency: transitive description: @@ -958,5 +1212,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.6.1 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 61a4d25..43c044a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,11 +39,21 @@ dependencies: go_router: ^14.8.1 freezed_annotation: ^3.0.0 json_annotation: ^4.9.0 - flutter_blue_plus: ^1.35.3 + flutter_blue_plus: ^2.1.0 rust: ^3.1.0 anyhow: ^3.0.1 logging: ^1.3.0 shared_preferences: ^2.5.2 + drift: ^2.26.0 + drift_flutter: ^0.2.4 + path_provider: ^2.1.5 + rxdart: ^0.28.0 + rust_lib_abawo_bt_app: + path: rust_builder + flutter_rust_bridge: 2.11.1 + flutter_reactive_ble: ^5.4.0 + nb_utils: ^7.2.0 + cbor: ^6.3.3 dev_dependencies: flutter_test: @@ -61,6 +71,9 @@ dev_dependencies: riverpod_lint: ^2.6.5 freezed: ^3.0.4 json_serializable: ^6.9.4 + drift_dev: ^2.26.0 + integration_test: + sdk: flutter # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -77,6 +90,8 @@ flutter: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg + assets: + - assets/images/shifter-wireframe.png # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..fc202ba --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,729 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + +[[package]] +name = "allo-isolate" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "449e356a4864c017286dbbec0e12767ea07efba29e3b7d984194c2a7ff3c4550" +dependencies = [ + "anyhow", + "atomic", + "backtrace", +] + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "anyhow" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "build-target" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b" + +[[package]] +name = "bumpalo" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" + +[[package]] +name = "bytemuck" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dart-sys" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57967e4b200d767d091b961d6ab42cc7d0cc14fe9e052e75d0d3cf9eb732d895" +dependencies = [ + "cc", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "delegate-attr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51aac4c99b2e6775164b412ea33ae8441b2fde2dbf05a20bc0052a63d08c475b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "flutter_rust_bridge" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde126295b2acc5f0a712e265e91b6fdc0ed38767496483e592ae7134db83725" +dependencies = [ + "allo-isolate", + "android_logger", + "anyhow", + "build-target", + "bytemuck", + "byteorder", + "console_error_panic_hook", + "dart-sys", + "delegate-attr", + "flutter_rust_bridge_macros", + "futures", + "js-sys", + "lazy_static", + "log", + "oslog", + "portable-atomic", + "threadpool", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "flutter_rust_bridge_macros" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f0420326b13675321b194928bb7830043b68cf8b810e1c651285c747abb080" +dependencies = [ + "hex", + "md-5", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" + +[[package]] +name = "futures-executor" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" + +[[package]] +name = "futures-macro" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" + +[[package]] +name = "futures-task" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" + +[[package]] +name = "futures-util" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hermit-abi" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "js-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "oslog" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" +dependencies = [ + "cc", + "dashmap", + "log", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "proc-macro2" +version = "1.0.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + +[[package]] +name = "rust_lib_abawo_bt_app" +version = "0.1.0" +dependencies = [ + "flutter_rust_bridge", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "tokio" +version = "1.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" +dependencies = [ + "backtrace", + "num_cpus", + "pin-project-lite", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + +[[package]] +name = "web-sys" +version = "0.3.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c24a44ec86bb68fbecd1b3efed7e85ea5621b39b35ef2766b66cd984f8010f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..912a50d --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "rust_lib_abawo_bt_app" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "staticlib"] + +[dependencies] +flutter_rust_bridge = "=2.11.1" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] } diff --git a/rust/src/api/mod.rs b/rust/src/api/mod.rs new file mode 100644 index 0000000..b252f36 --- /dev/null +++ b/rust/src/api/mod.rs @@ -0,0 +1 @@ +pub mod simple; diff --git a/rust/src/api/simple.rs b/rust/src/api/simple.rs new file mode 100644 index 0000000..72c63f8 --- /dev/null +++ b/rust/src/api/simple.rs @@ -0,0 +1,12 @@ +#[flutter_rust_bridge::frb(sync)] // Synchronous mode for simplicity of the demo +pub fn greet(name: String) -> String { + format!("Hello, {name}!") +} + +#[flutter_rust_bridge::frb(init)] +pub fn init_app() { + // Default utilities - feel free to customize + flutter_rust_bridge::setup_default_user_utils(); +} + + diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs new file mode 100644 index 0000000..4f41c47 --- /dev/null +++ b/rust/src/frb_generated.rs @@ -0,0 +1,276 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +#![allow( + non_camel_case_types, + unused, + non_snake_case, + clippy::needless_return, + clippy::redundant_closure_call, + clippy::redundant_closure, + clippy::useless_conversion, + clippy::unit_arg, + clippy::unused_unit, + clippy::double_parens, + clippy::let_and_return, + clippy::too_many_arguments, + clippy::match_single_binding, + clippy::clone_on_copy, + clippy::let_unit_value, + clippy::deref_addrof, + clippy::explicit_auto_deref, + clippy::borrow_deref_ref, + clippy::needless_borrow +)] + +// Section: imports + +use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt}; +use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; +use flutter_rust_bridge::{Handler, IntoIntoDart}; + +// Section: boilerplate + +flutter_rust_bridge::frb_generated_boilerplate!( + default_stream_sink_codec = SseCodec, + default_rust_opaque = RustOpaqueMoi, + default_rust_auto_opaque = RustAutoOpaqueMoi, +); +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1918914929; + +// Section: executor + +flutter_rust_bridge::frb_generated_default_handler!(); + +// Section: wire_funcs + +fn wire__crate__api__simple__greet_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "greet", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_name = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::simple::greet(api_name))?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__simple__init_app_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "init_app", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::simple::init_app(); + })?; + Ok(output_ok) + })()) + } + }, + ) +} + +// Section: dart2rust + +impl SseDecode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = >::sse_decode(deserializer); + return String::from_utf8(inner).unwrap(); + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() + } +} + +impl SseDecode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {} +} + +impl SseDecode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i32::().unwrap() + } +} + +impl SseDecode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() != 0 + } +} + +fn pde_ffi_dispatcher_primary_impl( + func_id: i32, + port: flutter_rust_bridge::for_generated::MessagePort, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 2 => wire__crate__api__simple__init_app_impl(port, ptr, rust_vec_len, data_len), + _ => unreachable!(), + } +} + +fn pde_ffi_dispatcher_sync_impl( + func_id: i32, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 1 => wire__crate__api__simple__greet_impl(ptr, rust_vec_len, data_len), + _ => unreachable!(), + } +} + +// Section: rust2dart + +impl SseEncode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.into_bytes(), serializer); + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self).unwrap(); + } +} + +impl SseEncode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {} +} + +impl SseEncode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i32::(self).unwrap(); + } +} + +impl SseEncode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self as _).unwrap(); + } +} + +#[cfg(not(target_family = "wasm"))] +mod io { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.11.1. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_io!(); +} +#[cfg(not(target_family = "wasm"))] +pub use io::*; + +/// cbindgen:ignore +#[cfg(target_family = "wasm")] +mod web { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.11.1. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::wasm_bindgen; + use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_web!(); +} +#[cfg(target_family = "wasm")] +pub use web::*; diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..cbb071f --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,2 @@ +pub mod api; +mod frb_generated; diff --git a/rust_builder/.gitignore b/rust_builder/.gitignore new file mode 100644 index 0000000..ac5aa98 --- /dev/null +++ b/rust_builder/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ diff --git a/rust_builder/README.md b/rust_builder/README.md new file mode 100644 index 0000000..922615f --- /dev/null +++ b/rust_builder/README.md @@ -0,0 +1 @@ +Please ignore this folder, which is just glue to build Rust with Flutter. \ No newline at end of file diff --git a/rust_builder/android/.gitignore b/rust_builder/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/rust_builder/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/rust_builder/android/build.gradle b/rust_builder/android/build.gradle new file mode 100644 index 0000000..041ec19 --- /dev/null +++ b/rust_builder/android/build.gradle @@ -0,0 +1,56 @@ +// The Android Gradle Plugin builds the native code with the Android NDK. + +group 'com.flutter_rust_bridge.rust_lib_abawo_bt_app' +version '1.0' + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + // The Android Gradle Plugin knows how to build native code with the NDK. + classpath 'com.android.tools.build:gradle:7.3.0' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' + +android { + if (project.android.hasProperty("namespace")) { + namespace 'com.flutter_rust_bridge.rust_lib_abawo_bt_app' + } + + // Bumping the plugin compileSdkVersion requires all clients of this plugin + // to bump the version in their app. + compileSdkVersion 33 + + // Use the NDK version + // declared in /android/app/build.gradle file of the Flutter project. + // Replace it with a version number if this plugin requires a specfic NDK version. + // (e.g. ndkVersion "23.1.7779620") + ndkVersion android.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion 19 + } +} + +apply from: "../cargokit/gradle/plugin.gradle" +cargokit { + manifestDir = "../../rust" + libname = "rust_lib_abawo_bt_app" +} diff --git a/rust_builder/android/settings.gradle b/rust_builder/android/settings.gradle new file mode 100644 index 0000000..dc06525 --- /dev/null +++ b/rust_builder/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'rust_lib_abawo_bt_app' diff --git a/rust_builder/android/src/main/AndroidManifest.xml b/rust_builder/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2b4fcee --- /dev/null +++ b/rust_builder/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/rust_builder/cargokit/.gitignore b/rust_builder/cargokit/.gitignore new file mode 100644 index 0000000..cf7bb86 --- /dev/null +++ b/rust_builder/cargokit/.gitignore @@ -0,0 +1,4 @@ +target +.dart_tool +*.iml +!pubspec.lock diff --git a/rust_builder/cargokit/LICENSE b/rust_builder/cargokit/LICENSE new file mode 100644 index 0000000..d33a5fe --- /dev/null +++ b/rust_builder/cargokit/LICENSE @@ -0,0 +1,42 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +Copyright 2022 Matej Knopp + +================================================================================ + +MIT LICENSE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + +APACHE LICENSE, VERSION 2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + diff --git a/rust_builder/cargokit/README b/rust_builder/cargokit/README new file mode 100644 index 0000000..398474d --- /dev/null +++ b/rust_builder/cargokit/README @@ -0,0 +1,11 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +Experimental repository to provide glue for seamlessly integrating cargo build +with flutter plugins and packages. + +See https://matejknopp.com/post/flutter_plugin_in_rust_with_no_prebuilt_binaries/ +for a tutorial on how to use Cargokit. + +Example plugin available at https://github.com/irondash/hello_rust_ffi_plugin. + diff --git a/rust_builder/cargokit/build_pod.sh b/rust_builder/cargokit/build_pod.sh new file mode 100755 index 0000000..ed0e0d9 --- /dev/null +++ b/rust_builder/cargokit/build_pod.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -e + +BASEDIR=$(dirname "$0") + +# Workaround for https://github.com/dart-lang/pub/issues/4010 +BASEDIR=$(cd "$BASEDIR" ; pwd -P) + +# Remove XCode SDK from path. Otherwise this breaks tool compilation when building iOS project +NEW_PATH=`echo $PATH | tr ":" "\n" | grep -v "Contents/Developer/" | tr "\n" ":"` + +export PATH=${NEW_PATH%?} # remove trailing : + +env + +# Platform name (macosx, iphoneos, iphonesimulator) +export CARGOKIT_DARWIN_PLATFORM_NAME=$PLATFORM_NAME + +# Arctive architectures (arm64, armv7, x86_64), space separated. +export CARGOKIT_DARWIN_ARCHS=$ARCHS + +# Current build configuration (Debug, Release) +export CARGOKIT_CONFIGURATION=$CONFIGURATION + +# Path to directory containing Cargo.toml. +export CARGOKIT_MANIFEST_DIR=$PODS_TARGET_SRCROOT/$1 + +# Temporary directory for build artifacts. +export CARGOKIT_TARGET_TEMP_DIR=$TARGET_TEMP_DIR + +# Output directory for final artifacts. +export CARGOKIT_OUTPUT_DIR=$PODS_CONFIGURATION_BUILD_DIR/$PRODUCT_NAME + +# Directory to store built tool artifacts. +export CARGOKIT_TOOL_TEMP_DIR=$TARGET_TEMP_DIR/build_tool + +# Directory inside root project. Not necessarily the top level directory of root project. +export CARGOKIT_ROOT_PROJECT_DIR=$SRCROOT + +FLUTTER_EXPORT_BUILD_ENVIRONMENT=( + "$PODS_ROOT/../Flutter/ephemeral/flutter_export_environment.sh" # macOS + "$PODS_ROOT/../Flutter/flutter_export_environment.sh" # iOS +) + +for path in "${FLUTTER_EXPORT_BUILD_ENVIRONMENT[@]}" +do + if [[ -f "$path" ]]; then + source "$path" + fi +done + +sh "$BASEDIR/run_build_tool.sh" build-pod "$@" + +# Make a symlink from built framework to phony file, which will be used as input to +# build script. This should force rebuild (podspec currently doesn't support alwaysOutOfDate +# attribute on custom build phase) +ln -fs "$OBJROOT/XCBuildData/build.db" "${BUILT_PRODUCTS_DIR}/cargokit_phony" +ln -fs "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}" "${BUILT_PRODUCTS_DIR}/cargokit_phony_out" diff --git a/rust_builder/cargokit/build_tool/README.md b/rust_builder/cargokit/build_tool/README.md new file mode 100644 index 0000000..a878c27 --- /dev/null +++ b/rust_builder/cargokit/build_tool/README.md @@ -0,0 +1,5 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +A sample command-line application with an entrypoint in `bin/`, library code +in `lib/`, and example unit test in `test/`. diff --git a/rust_builder/cargokit/build_tool/analysis_options.yaml b/rust_builder/cargokit/build_tool/analysis_options.yaml new file mode 100644 index 0000000..0e16a8b --- /dev/null +++ b/rust_builder/cargokit/build_tool/analysis_options.yaml @@ -0,0 +1,34 @@ +# This is copied from Cargokit (which is the official way to use it currently) +# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +linter: + rules: + - prefer_relative_imports + - directives_ordering + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/rust_builder/cargokit/build_tool/bin/build_tool.dart b/rust_builder/cargokit/build_tool/bin/build_tool.dart new file mode 100644 index 0000000..268eb52 --- /dev/null +++ b/rust_builder/cargokit/build_tool/bin/build_tool.dart @@ -0,0 +1,8 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'package:build_tool/build_tool.dart' as build_tool; + +void main(List arguments) { + build_tool.runMain(arguments); +} diff --git a/rust_builder/cargokit/build_tool/lib/build_tool.dart b/rust_builder/cargokit/build_tool/lib/build_tool.dart new file mode 100644 index 0000000..7c1bb75 --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/build_tool.dart @@ -0,0 +1,8 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'src/build_tool.dart' as build_tool; + +Future runMain(List args) async { + return build_tool.runMain(args); +} diff --git a/rust_builder/cargokit/build_tool/lib/src/android_environment.dart b/rust_builder/cargokit/build_tool/lib/src/android_environment.dart new file mode 100644 index 0000000..15fc9ee --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/android_environment.dart @@ -0,0 +1,195 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; +import 'dart:isolate'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as path; +import 'package:version/version.dart'; + +import 'target.dart'; +import 'util.dart'; + +class AndroidEnvironment { + AndroidEnvironment({ + required this.sdkPath, + required this.ndkVersion, + required this.minSdkVersion, + required this.targetTempDir, + required this.target, + }); + + static void clangLinkerWrapper(List args) { + final clang = Platform.environment['_CARGOKIT_NDK_LINK_CLANG']; + if (clang == null) { + throw Exception( + "cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_CLANG env var"); + } + final target = Platform.environment['_CARGOKIT_NDK_LINK_TARGET']; + if (target == null) { + throw Exception( + "cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_TARGET env var"); + } + + runCommand(clang, [ + target, + ...args, + ]); + } + + /// Full path to Android SDK. + final String sdkPath; + + /// Full version of Android NDK. + final String ndkVersion; + + /// Minimum supported SDK version. + final int minSdkVersion; + + /// Target directory for build artifacts. + final String targetTempDir; + + /// Target being built. + final Target target; + + bool ndkIsInstalled() { + final ndkPath = path.join(sdkPath, 'ndk', ndkVersion); + final ndkPackageXml = File(path.join(ndkPath, 'package.xml')); + return ndkPackageXml.existsSync(); + } + + void installNdk({ + required String javaHome, + }) { + final sdkManagerExtension = Platform.isWindows ? '.bat' : ''; + final sdkManager = path.join( + sdkPath, + 'cmdline-tools', + 'latest', + 'bin', + 'sdkmanager$sdkManagerExtension', + ); + + log.info('Installing NDK $ndkVersion'); + runCommand(sdkManager, [ + '--install', + 'ndk;$ndkVersion', + ], environment: { + 'JAVA_HOME': javaHome, + }); + } + + Future> buildEnvironment() async { + final hostArch = Platform.isMacOS + ? "darwin-x86_64" + : (Platform.isLinux ? "linux-x86_64" : "windows-x86_64"); + + final ndkPath = path.join(sdkPath, 'ndk', ndkVersion); + final toolchainPath = path.join( + ndkPath, + 'toolchains', + 'llvm', + 'prebuilt', + hostArch, + 'bin', + ); + + final minSdkVersion = + math.max(target.androidMinSdkVersion!, this.minSdkVersion); + + final exe = Platform.isWindows ? '.exe' : ''; + + final arKey = 'AR_${target.rust}'; + final arValue = ['${target.rust}-ar', 'llvm-ar', 'llvm-ar.exe'] + .map((e) => path.join(toolchainPath, e)) + .firstWhereOrNull((element) => File(element).existsSync()); + if (arValue == null) { + throw Exception('Failed to find ar for $target in $toolchainPath'); + } + + final targetArg = '--target=${target.rust}$minSdkVersion'; + + final ccKey = 'CC_${target.rust}'; + final ccValue = path.join(toolchainPath, 'clang$exe'); + final cfFlagsKey = 'CFLAGS_${target.rust}'; + final cFlagsValue = targetArg; + + final cxxKey = 'CXX_${target.rust}'; + final cxxValue = path.join(toolchainPath, 'clang++$exe'); + final cxxFlagsKey = 'CXXFLAGS_${target.rust}'; + final cxxFlagsValue = targetArg; + + final linkerKey = + 'cargo_target_${target.rust.replaceAll('-', '_')}_linker'.toUpperCase(); + + final ranlibKey = 'RANLIB_${target.rust}'; + final ranlibValue = path.join(toolchainPath, 'llvm-ranlib$exe'); + + final ndkVersionParsed = Version.parse(ndkVersion); + final rustFlagsKey = 'CARGO_ENCODED_RUSTFLAGS'; + final rustFlagsValue = _libGccWorkaround(targetTempDir, ndkVersionParsed); + + final runRustTool = + Platform.isWindows ? 'run_build_tool.cmd' : 'run_build_tool.sh'; + + final packagePath = (await Isolate.resolvePackageUri( + Uri.parse('package:build_tool/buildtool.dart')))! + .toFilePath(); + final selfPath = path.canonicalize(path.join( + packagePath, + '..', + '..', + '..', + runRustTool, + )); + + // Make sure that run_build_tool is working properly even initially launched directly + // through dart run. + final toolTempDir = + Platform.environment['CARGOKIT_TOOL_TEMP_DIR'] ?? targetTempDir; + + return { + arKey: arValue, + ccKey: ccValue, + cfFlagsKey: cFlagsValue, + cxxKey: cxxValue, + cxxFlagsKey: cxxFlagsValue, + ranlibKey: ranlibValue, + rustFlagsKey: rustFlagsValue, + linkerKey: selfPath, + // Recognized by main() so we know when we're acting as a wrapper + '_CARGOKIT_NDK_LINK_TARGET': targetArg, + '_CARGOKIT_NDK_LINK_CLANG': ccValue, + 'CARGOKIT_TOOL_TEMP_DIR': toolTempDir, + }; + } + + // Workaround for libgcc missing in NDK23, inspired by cargo-ndk + String _libGccWorkaround(String buildDir, Version ndkVersion) { + final workaroundDir = path.join( + buildDir, + 'cargokit', + 'libgcc_workaround', + '${ndkVersion.major}', + ); + Directory(workaroundDir).createSync(recursive: true); + if (ndkVersion.major >= 23) { + File(path.join(workaroundDir, 'libgcc.a')) + .writeAsStringSync('INPUT(-lunwind)'); + } else { + // Other way around, untested, forward libgcc.a from libunwind once Rust + // gets updated for NDK23+. + File(path.join(workaroundDir, 'libunwind.a')) + .writeAsStringSync('INPUT(-lgcc)'); + } + + var rustFlags = Platform.environment['CARGO_ENCODED_RUSTFLAGS'] ?? ''; + if (rustFlags.isNotEmpty) { + rustFlags = '$rustFlags\x1f'; + } + rustFlags = '$rustFlags-L\x1f$workaroundDir'; + return rustFlags; + } +} diff --git a/rust_builder/cargokit/build_tool/lib/src/artifacts_provider.dart b/rust_builder/cargokit/build_tool/lib/src/artifacts_provider.dart new file mode 100644 index 0000000..e608cec --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/artifacts_provider.dart @@ -0,0 +1,266 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:http/http.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'builder.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'rustup.dart'; +import 'target.dart'; + +class Artifact { + /// File system location of the artifact. + final String path; + + /// Actual file name that the artifact should have in destination folder. + final String finalFileName; + + AritifactType get type { + if (finalFileName.endsWith('.dll') || + finalFileName.endsWith('.dll.lib') || + finalFileName.endsWith('.pdb') || + finalFileName.endsWith('.so') || + finalFileName.endsWith('.dylib')) { + return AritifactType.dylib; + } else if (finalFileName.endsWith('.lib') || finalFileName.endsWith('.a')) { + return AritifactType.staticlib; + } else { + throw Exception('Unknown artifact type for $finalFileName'); + } + } + + Artifact({ + required this.path, + required this.finalFileName, + }); +} + +final _log = Logger('artifacts_provider'); + +class ArtifactProvider { + ArtifactProvider({ + required this.environment, + required this.userOptions, + }); + + final BuildEnvironment environment; + final CargokitUserOptions userOptions; + + Future>> getArtifacts(List targets) async { + final result = await _getPrecompiledArtifacts(targets); + + final pendingTargets = List.of(targets); + pendingTargets.removeWhere((element) => result.containsKey(element)); + + if (pendingTargets.isEmpty) { + return result; + } + + final rustup = Rustup(); + for (final target in targets) { + final builder = RustBuilder(target: target, environment: environment); + builder.prepare(rustup); + _log.info('Building ${environment.crateInfo.packageName} for $target'); + final targetDir = await builder.build(); + // For local build accept both static and dynamic libraries. + final artifactNames = { + ...getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + aritifactType: AritifactType.dylib, + remote: false, + ), + ...getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + aritifactType: AritifactType.staticlib, + remote: false, + ) + }; + final artifacts = artifactNames + .map((artifactName) => Artifact( + path: path.join(targetDir, artifactName), + finalFileName: artifactName, + )) + .where((element) => File(element.path).existsSync()) + .toList(); + result[target] = artifacts; + } + return result; + } + + Future>> _getPrecompiledArtifacts( + List targets) async { + if (userOptions.usePrecompiledBinaries == false) { + _log.info('Precompiled binaries are disabled'); + return {}; + } + if (environment.crateOptions.precompiledBinaries == null) { + _log.fine('Precompiled binaries not enabled for this crate'); + return {}; + } + + final start = Stopwatch()..start(); + final crateHash = CrateHash.compute(environment.manifestDir, + tempStorage: environment.targetTempDir); + _log.fine( + 'Computed crate hash $crateHash in ${start.elapsedMilliseconds}ms'); + + final downloadedArtifactsDir = + path.join(environment.targetTempDir, 'precompiled', crateHash); + Directory(downloadedArtifactsDir).createSync(recursive: true); + + final res = >{}; + + for (final target in targets) { + final requiredArtifacts = getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + remote: true, + ); + final artifactsForTarget = []; + + for (final artifact in requiredArtifacts) { + final fileName = PrecompileBinaries.fileName(target, artifact); + final downloadedPath = path.join(downloadedArtifactsDir, fileName); + if (!File(downloadedPath).existsSync()) { + final signatureFileName = + PrecompileBinaries.signatureFileName(target, artifact); + await _tryDownloadArtifacts( + crateHash: crateHash, + fileName: fileName, + signatureFileName: signatureFileName, + finalPath: downloadedPath, + ); + } + if (File(downloadedPath).existsSync()) { + artifactsForTarget.add(Artifact( + path: downloadedPath, + finalFileName: artifact, + )); + } else { + break; + } + } + + // Only provide complete set of artifacts. + if (artifactsForTarget.length == requiredArtifacts.length) { + _log.fine('Found precompiled artifacts for $target'); + res[target] = artifactsForTarget; + } + } + + return res; + } + + static Future _get(Uri url, {Map? headers}) async { + int attempt = 0; + const maxAttempts = 10; + while (true) { + try { + return await get(url, headers: headers); + } on SocketException catch (e) { + // Try to detect reset by peer error and retry. + if (attempt++ < maxAttempts && + (e.osError?.errorCode == 54 || e.osError?.errorCode == 10054)) { + _log.severe( + 'Failed to download $url: $e, attempt $attempt of $maxAttempts, will retry...'); + await Future.delayed(Duration(seconds: 1)); + continue; + } else { + rethrow; + } + } + } + } + + Future _tryDownloadArtifacts({ + required String crateHash, + required String fileName, + required String signatureFileName, + required String finalPath, + }) async { + final precompiledBinaries = environment.crateOptions.precompiledBinaries!; + final prefix = precompiledBinaries.uriPrefix; + final url = Uri.parse('$prefix$crateHash/$fileName'); + final signatureUrl = Uri.parse('$prefix$crateHash/$signatureFileName'); + _log.fine('Downloading signature from $signatureUrl'); + final signature = await _get(signatureUrl); + if (signature.statusCode == 404) { + _log.warning( + 'Precompiled binaries not available for crate hash $crateHash ($fileName)'); + return; + } + if (signature.statusCode != 200) { + _log.severe( + 'Failed to download signature $signatureUrl: status ${signature.statusCode}'); + return; + } + _log.fine('Downloading binary from $url'); + final res = await _get(url); + if (res.statusCode != 200) { + _log.severe('Failed to download binary $url: status ${res.statusCode}'); + return; + } + if (verify( + precompiledBinaries.publicKey, res.bodyBytes, signature.bodyBytes)) { + File(finalPath).writeAsBytesSync(res.bodyBytes); + } else { + _log.shout('Signature verification failed! Ignoring binary.'); + } + } +} + +enum AritifactType { + staticlib, + dylib, +} + +AritifactType artifactTypeForTarget(Target target) { + if (target.darwinPlatform != null) { + return AritifactType.staticlib; + } else { + return AritifactType.dylib; + } +} + +List getArtifactNames({ + required Target target, + required String libraryName, + required bool remote, + AritifactType? aritifactType, +}) { + aritifactType ??= artifactTypeForTarget(target); + if (target.darwinArch != null) { + if (aritifactType == AritifactType.staticlib) { + return ['lib$libraryName.a']; + } else { + return ['lib$libraryName.dylib']; + } + } else if (target.rust.contains('-windows-')) { + if (aritifactType == AritifactType.staticlib) { + return ['$libraryName.lib']; + } else { + return [ + '$libraryName.dll', + '$libraryName.dll.lib', + if (!remote) '$libraryName.pdb' + ]; + } + } else if (target.rust.contains('-linux-')) { + if (aritifactType == AritifactType.staticlib) { + return ['lib$libraryName.a']; + } else { + return ['lib$libraryName.so']; + } + } else { + throw Exception("Unsupported target: ${target.rust}"); + } +} diff --git a/rust_builder/cargokit/build_tool/lib/src/build_cmake.dart b/rust_builder/cargokit/build_tool/lib/src/build_cmake.dart new file mode 100644 index 0000000..6f3b2a4 --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/build_cmake.dart @@ -0,0 +1,40 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; + +class BuildCMake { + final CargokitUserOptions userOptions; + + BuildCMake({required this.userOptions}); + + Future build() async { + final targetPlatform = Environment.targetPlatform; + final target = Target.forFlutterName(Environment.targetPlatform); + if (target == null) { + throw Exception("Unknown target platform: $targetPlatform"); + } + + final environment = BuildEnvironment.fromEnvironment(isAndroid: false); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts([target]); + + final libs = artifacts[target]!; + + for (final lib in libs) { + if (lib.type == AritifactType.dylib) { + File(lib.path) + .copySync(path.join(Environment.outputDir, lib.finalFileName)); + } + } + } +} diff --git a/rust_builder/cargokit/build_tool/lib/src/build_gradle.dart b/rust_builder/cargokit/build_tool/lib/src/build_gradle.dart new file mode 100644 index 0000000..7e61fcb --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/build_gradle.dart @@ -0,0 +1,49 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; + +final log = Logger('build_gradle'); + +class BuildGradle { + BuildGradle({required this.userOptions}); + + final CargokitUserOptions userOptions; + + Future build() async { + final targets = Environment.targetPlatforms.map((arch) { + final target = Target.forFlutterName(arch); + if (target == null) { + throw Exception( + "Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}"); + } + return target; + }).toList(); + + final environment = BuildEnvironment.fromEnvironment(isAndroid: true); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts(targets); + + for (final target in targets) { + final libs = artifacts[target]!; + final outputDir = path.join(Environment.outputDir, target.android!); + Directory(outputDir).createSync(recursive: true); + + for (final lib in libs) { + if (lib.type == AritifactType.dylib) { + File(lib.path).copySync(path.join(outputDir, lib.finalFileName)); + } + } + } + } +} diff --git a/rust_builder/cargokit/build_tool/lib/src/build_pod.dart b/rust_builder/cargokit/build_tool/lib/src/build_pod.dart new file mode 100644 index 0000000..8a9c0db --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/build_pod.dart @@ -0,0 +1,89 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; +import 'util.dart'; + +class BuildPod { + BuildPod({required this.userOptions}); + + final CargokitUserOptions userOptions; + + Future build() async { + final targets = Environment.darwinArchs.map((arch) { + final target = Target.forDarwin( + platformName: Environment.darwinPlatformName, darwinAarch: arch); + if (target == null) { + throw Exception( + "Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}"); + } + return target; + }).toList(); + + final environment = BuildEnvironment.fromEnvironment(isAndroid: false); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts(targets); + + void performLipo(String targetFile, Iterable sourceFiles) { + runCommand("lipo", [ + '-create', + ...sourceFiles, + '-output', + targetFile, + ]); + } + + final outputDir = Environment.outputDir; + + Directory(outputDir).createSync(recursive: true); + + final staticLibs = artifacts.values + .expand((element) => element) + .where((element) => element.type == AritifactType.staticlib) + .toList(); + final dynamicLibs = artifacts.values + .expand((element) => element) + .where((element) => element.type == AritifactType.dylib) + .toList(); + + final libName = environment.crateInfo.packageName; + + // If there is static lib, use it and link it with pod + if (staticLibs.isNotEmpty) { + final finalTargetFile = path.join(outputDir, "lib$libName.a"); + performLipo(finalTargetFile, staticLibs.map((e) => e.path)); + } else { + // Otherwise try to replace bundle dylib with our dylib + final bundlePaths = [ + '$libName.framework/Versions/A/$libName', + '$libName.framework/$libName', + ]; + + for (final bundlePath in bundlePaths) { + final targetFile = path.join(outputDir, bundlePath); + if (File(targetFile).existsSync()) { + performLipo(targetFile, dynamicLibs.map((e) => e.path)); + + // Replace absolute id with @rpath one so that it works properly + // when moved to Frameworks. + runCommand("install_name_tool", [ + '-id', + '@rpath/$bundlePath', + targetFile, + ]); + return; + } + } + throw Exception('Unable to find bundle for dynamic library'); + } + } +} diff --git a/rust_builder/cargokit/build_tool/lib/src/build_tool.dart b/rust_builder/cargokit/build_tool/lib/src/build_tool.dart new file mode 100644 index 0000000..c8f3698 --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/build_tool.dart @@ -0,0 +1,271 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:github/github.dart'; +import 'package:hex/hex.dart'; +import 'package:logging/logging.dart'; + +import 'android_environment.dart'; +import 'build_cmake.dart'; +import 'build_gradle.dart'; +import 'build_pod.dart'; +import 'logging.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'target.dart'; +import 'util.dart'; +import 'verify_binaries.dart'; + +final log = Logger('build_tool'); + +abstract class BuildCommand extends Command { + Future runBuildCommand(CargokitUserOptions options); + + @override + Future run() async { + final options = CargokitUserOptions.load(); + + if (options.verboseLogging || + Platform.environment['CARGOKIT_VERBOSE'] == '1') { + enableVerboseLogging(); + } + + await runBuildCommand(options); + } +} + +class BuildPodCommand extends BuildCommand { + @override + final name = 'build-pod'; + + @override + final description = 'Build cocoa pod library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildPod(userOptions: options); + await build.build(); + } +} + +class BuildGradleCommand extends BuildCommand { + @override + final name = 'build-gradle'; + + @override + final description = 'Build android library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildGradle(userOptions: options); + await build.build(); + } +} + +class BuildCMakeCommand extends BuildCommand { + @override + final name = 'build-cmake'; + + @override + final description = 'Build CMake library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildCMake(userOptions: options); + await build.build(); + } +} + +class GenKeyCommand extends Command { + @override + final name = 'gen-key'; + + @override + final description = 'Generate key pair for signing precompiled binaries'; + + @override + void run() { + final kp = generateKey(); + final private = HEX.encode(kp.privateKey.bytes); + final public = HEX.encode(kp.publicKey.bytes); + print("Private Key: $private"); + print("Public Key: $public"); + } +} + +class PrecompileBinariesCommand extends Command { + PrecompileBinariesCommand() { + argParser + ..addOption( + 'repository', + mandatory: true, + help: 'Github repository slug in format owner/name', + ) + ..addOption( + 'manifest-dir', + mandatory: true, + help: 'Directory containing Cargo.toml', + ) + ..addMultiOption('target', + help: 'Rust target triple of artifact to build.\n' + 'Can be specified multiple times or omitted in which case\n' + 'all targets for current platform will be built.') + ..addOption( + 'android-sdk-location', + help: 'Location of Android SDK (if available)', + ) + ..addOption( + 'android-ndk-version', + help: 'Android NDK version (if available)', + ) + ..addOption( + 'android-min-sdk-version', + help: 'Android minimum rquired version (if available)', + ) + ..addOption( + 'temp-dir', + help: 'Directory to store temporary build artifacts', + ) + ..addFlag( + "verbose", + abbr: "v", + defaultsTo: false, + help: "Enable verbose logging", + ); + } + + @override + final name = 'precompile-binaries'; + + @override + final description = 'Prebuild and upload binaries\n' + 'Private key must be passed through PRIVATE_KEY environment variable. ' + 'Use gen_key through generate priave key.\n' + 'Github token must be passed as GITHUB_TOKEN environment variable.\n'; + + @override + Future run() async { + final verbose = argResults!['verbose'] as bool; + if (verbose) { + enableVerboseLogging(); + } + + final privateKeyString = Platform.environment['PRIVATE_KEY']; + if (privateKeyString == null) { + throw ArgumentError('Missing PRIVATE_KEY environment variable'); + } + final githubToken = Platform.environment['GITHUB_TOKEN']; + if (githubToken == null) { + throw ArgumentError('Missing GITHUB_TOKEN environment variable'); + } + final privateKey = HEX.decode(privateKeyString); + if (privateKey.length != 64) { + throw ArgumentError('Private key must be 64 bytes long'); + } + final manifestDir = argResults!['manifest-dir'] as String; + if (!Directory(manifestDir).existsSync()) { + throw ArgumentError('Manifest directory does not exist: $manifestDir'); + } + String? androidMinSdkVersionString = + argResults!['android-min-sdk-version'] as String?; + int? androidMinSdkVersion; + if (androidMinSdkVersionString != null) { + androidMinSdkVersion = int.tryParse(androidMinSdkVersionString); + if (androidMinSdkVersion == null) { + throw ArgumentError( + 'Invalid android-min-sdk-version: $androidMinSdkVersionString'); + } + } + final targetStrigns = argResults!['target'] as List; + final targets = targetStrigns.map((target) { + final res = Target.forRustTriple(target); + if (res == null) { + throw ArgumentError('Invalid target: $target'); + } + return res; + }).toList(growable: false); + final precompileBinaries = PrecompileBinaries( + privateKey: PrivateKey(privateKey), + githubToken: githubToken, + manifestDir: manifestDir, + repositorySlug: RepositorySlug.full(argResults!['repository'] as String), + targets: targets, + androidSdkLocation: argResults!['android-sdk-location'] as String?, + androidNdkVersion: argResults!['android-ndk-version'] as String?, + androidMinSdkVersion: androidMinSdkVersion, + tempDir: argResults!['temp-dir'] as String?, + ); + + await precompileBinaries.run(); + } +} + +class VerifyBinariesCommand extends Command { + VerifyBinariesCommand() { + argParser.addOption( + 'manifest-dir', + mandatory: true, + help: 'Directory containing Cargo.toml', + ); + } + + @override + final name = "verify-binaries"; + + @override + final description = 'Verifies published binaries\n' + 'Checks whether there is a binary published for each targets\n' + 'and checks the signature.'; + + @override + Future run() async { + final manifestDir = argResults!['manifest-dir'] as String; + final verifyBinaries = VerifyBinaries( + manifestDir: manifestDir, + ); + await verifyBinaries.run(); + } +} + +Future runMain(List args) async { + try { + // Init logging before options are loaded + initLogging(); + + if (Platform.environment['_CARGOKIT_NDK_LINK_TARGET'] != null) { + return AndroidEnvironment.clangLinkerWrapper(args); + } + + final runner = CommandRunner('build_tool', 'Cargokit built_tool') + ..addCommand(BuildPodCommand()) + ..addCommand(BuildGradleCommand()) + ..addCommand(BuildCMakeCommand()) + ..addCommand(GenKeyCommand()) + ..addCommand(PrecompileBinariesCommand()) + ..addCommand(VerifyBinariesCommand()); + + await runner.run(args); + } on ArgumentError catch (e) { + stderr.writeln(e.toString()); + exit(1); + } catch (e, s) { + log.severe(kDoubleSeparator); + log.severe('Cargokit BuildTool failed with error:'); + log.severe(kSeparator); + log.severe(e); + // This tells user to install Rust, there's no need to pollute the log with + // stack trace. + if (e is! RustupNotFoundException) { + log.severe(kSeparator); + log.severe(s); + log.severe(kSeparator); + log.severe('BuildTool arguments: $args'); + } + log.severe(kDoubleSeparator); + exit(1); + } +} diff --git a/rust_builder/cargokit/build_tool/lib/src/builder.dart b/rust_builder/cargokit/build_tool/lib/src/builder.dart new file mode 100644 index 0000000..84c46e4 --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/builder.dart @@ -0,0 +1,198 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'android_environment.dart'; +import 'cargo.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'rustup.dart'; +import 'target.dart'; +import 'util.dart'; + +final _log = Logger('builder'); + +enum BuildConfiguration { + debug, + release, + profile, +} + +extension on BuildConfiguration { + bool get isDebug => this == BuildConfiguration.debug; + String get rustName => switch (this) { + BuildConfiguration.debug => 'debug', + BuildConfiguration.release => 'release', + BuildConfiguration.profile => 'release', + }; +} + +class BuildException implements Exception { + final String message; + + BuildException(this.message); + + @override + String toString() { + return 'BuildException: $message'; + } +} + +class BuildEnvironment { + final BuildConfiguration configuration; + final CargokitCrateOptions crateOptions; + final String targetTempDir; + final String manifestDir; + final CrateInfo crateInfo; + + final bool isAndroid; + final String? androidSdkPath; + final String? androidNdkVersion; + final int? androidMinSdkVersion; + final String? javaHome; + + BuildEnvironment({ + required this.configuration, + required this.crateOptions, + required this.targetTempDir, + required this.manifestDir, + required this.crateInfo, + required this.isAndroid, + this.androidSdkPath, + this.androidNdkVersion, + this.androidMinSdkVersion, + this.javaHome, + }); + + static BuildConfiguration parseBuildConfiguration(String value) { + // XCode configuration adds the flavor to configuration name. + final firstSegment = value.split('-').first; + final buildConfiguration = BuildConfiguration.values.firstWhereOrNull( + (e) => e.name == firstSegment, + ); + if (buildConfiguration == null) { + _log.warning('Unknown build configuraiton $value, will assume release'); + return BuildConfiguration.release; + } + return buildConfiguration; + } + + static BuildEnvironment fromEnvironment({ + required bool isAndroid, + }) { + final buildConfiguration = + parseBuildConfiguration(Environment.configuration); + final manifestDir = Environment.manifestDir; + final crateOptions = CargokitCrateOptions.load( + manifestDir: manifestDir, + ); + final crateInfo = CrateInfo.load(manifestDir); + return BuildEnvironment( + configuration: buildConfiguration, + crateOptions: crateOptions, + targetTempDir: Environment.targetTempDir, + manifestDir: manifestDir, + crateInfo: crateInfo, + isAndroid: isAndroid, + androidSdkPath: isAndroid ? Environment.sdkPath : null, + androidNdkVersion: isAndroid ? Environment.ndkVersion : null, + androidMinSdkVersion: + isAndroid ? int.parse(Environment.minSdkVersion) : null, + javaHome: isAndroid ? Environment.javaHome : null, + ); + } +} + +class RustBuilder { + final Target target; + final BuildEnvironment environment; + + RustBuilder({ + required this.target, + required this.environment, + }); + + void prepare( + Rustup rustup, + ) { + final toolchain = _toolchain; + if (rustup.installedTargets(toolchain) == null) { + rustup.installToolchain(toolchain); + } + if (toolchain == 'nightly') { + rustup.installRustSrcForNightly(); + } + if (!rustup.installedTargets(toolchain)!.contains(target.rust)) { + rustup.installTarget(target.rust, toolchain: toolchain); + } + } + + CargoBuildOptions? get _buildOptions => + environment.crateOptions.cargo[environment.configuration]; + + String get _toolchain => _buildOptions?.toolchain.name ?? 'stable'; + + /// Returns the path of directory containing build artifacts. + Future build() async { + final extraArgs = _buildOptions?.flags ?? []; + final manifestPath = path.join(environment.manifestDir, 'Cargo.toml'); + runCommand( + 'rustup', + [ + 'run', + _toolchain, + 'cargo', + 'build', + ...extraArgs, + '--manifest-path', + manifestPath, + '-p', + environment.crateInfo.packageName, + if (!environment.configuration.isDebug) '--release', + '--target', + target.rust, + '--target-dir', + environment.targetTempDir, + ], + environment: await _buildEnvironment(), + ); + return path.join( + environment.targetTempDir, + target.rust, + environment.configuration.rustName, + ); + } + + Future> _buildEnvironment() async { + if (target.android == null) { + return {}; + } else { + final sdkPath = environment.androidSdkPath; + final ndkVersion = environment.androidNdkVersion; + final minSdkVersion = environment.androidMinSdkVersion; + if (sdkPath == null) { + throw BuildException('androidSdkPath is not set'); + } + if (ndkVersion == null) { + throw BuildException('androidNdkVersion is not set'); + } + if (minSdkVersion == null) { + throw BuildException('androidMinSdkVersion is not set'); + } + final env = AndroidEnvironment( + sdkPath: sdkPath, + ndkVersion: ndkVersion, + minSdkVersion: minSdkVersion, + targetTempDir: environment.targetTempDir, + target: target, + ); + if (!env.ndkIsInstalled() && environment.javaHome != null) { + env.installNdk(javaHome: environment.javaHome!); + } + return env.buildEnvironment(); + } + } +} diff --git a/rust_builder/cargokit/build_tool/lib/src/cargo.dart b/rust_builder/cargokit/build_tool/lib/src/cargo.dart new file mode 100644 index 0000000..0d8958f --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/cargo.dart @@ -0,0 +1,48 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:toml/toml.dart'; + +class ManifestException { + ManifestException(this.message, {required this.fileName}); + + final String? fileName; + final String message; + + @override + String toString() { + if (fileName != null) { + return 'Failed to parse package manifest at $fileName: $message'; + } else { + return 'Failed to parse package manifest: $message'; + } + } +} + +class CrateInfo { + CrateInfo({required this.packageName}); + + final String packageName; + + static CrateInfo parseManifest(String manifest, {final String? fileName}) { + final toml = TomlDocument.parse(manifest); + final package = toml.toMap()['package']; + if (package == null) { + throw ManifestException('Missing package section', fileName: fileName); + } + final name = package['name']; + if (name == null) { + throw ManifestException('Missing package name', fileName: fileName); + } + return CrateInfo(packageName: name); + } + + static CrateInfo load(String manifestDir) { + final manifestFile = File(path.join(manifestDir, 'Cargo.toml')); + final manifest = manifestFile.readAsStringSync(); + return parseManifest(manifest, fileName: manifestFile.path); + } +} diff --git a/rust_builder/cargokit/build_tool/lib/src/crate_hash.dart b/rust_builder/cargokit/build_tool/lib/src/crate_hash.dart new file mode 100644 index 0000000..0c4d88d --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/crate_hash.dart @@ -0,0 +1,124 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as path; + +class CrateHash { + /// Computes a hash uniquely identifying crate content. This takes into account + /// content all all .rs files inside the src directory, as well as Cargo.toml, + /// Cargo.lock, build.rs and cargokit.yaml. + /// + /// If [tempStorage] is provided, computed hash is stored in a file in that directory + /// and reused on subsequent calls if the crate content hasn't changed. + static String compute(String manifestDir, {String? tempStorage}) { + return CrateHash._( + manifestDir: manifestDir, + tempStorage: tempStorage, + )._compute(); + } + + CrateHash._({ + required this.manifestDir, + required this.tempStorage, + }); + + String _compute() { + final files = getFiles(); + final tempStorage = this.tempStorage; + if (tempStorage != null) { + final quickHash = _computeQuickHash(files); + final quickHashFolder = Directory(path.join(tempStorage, 'crate_hash')); + quickHashFolder.createSync(recursive: true); + final quickHashFile = File(path.join(quickHashFolder.path, quickHash)); + if (quickHashFile.existsSync()) { + return quickHashFile.readAsStringSync(); + } + final hash = _computeHash(files); + quickHashFile.writeAsStringSync(hash); + return hash; + } else { + return _computeHash(files); + } + } + + /// Computes a quick hash based on files stat (without reading contents). This + /// is used to cache the real hash, which is slower to compute since it involves + /// reading every single file. + String _computeQuickHash(List files) { + final output = AccumulatorSink(); + final input = sha256.startChunkedConversion(output); + + final data = ByteData(8); + for (final file in files) { + input.add(utf8.encode(file.path)); + final stat = file.statSync(); + data.setUint64(0, stat.size); + input.add(data.buffer.asUint8List()); + data.setUint64(0, stat.modified.millisecondsSinceEpoch); + input.add(data.buffer.asUint8List()); + } + + input.close(); + return base64Url.encode(output.events.single.bytes); + } + + String _computeHash(List files) { + final output = AccumulatorSink(); + final input = sha256.startChunkedConversion(output); + + void addTextFile(File file) { + // text Files are hashed by lines in case we're dealing with github checkout + // that auto-converts line endings. + final splitter = LineSplitter(); + if (file.existsSync()) { + final data = file.readAsStringSync(); + final lines = splitter.convert(data); + for (final line in lines) { + input.add(utf8.encode(line)); + } + } + } + + for (final file in files) { + addTextFile(file); + } + + input.close(); + final res = output.events.single; + + // Truncate to 128bits. + final hash = res.bytes.sublist(0, 16); + return hex.encode(hash); + } + + List getFiles() { + final src = Directory(path.join(manifestDir, 'src')); + final files = src + .listSync(recursive: true, followLinks: false) + .whereType() + .toList(); + files.sortBy((element) => element.path); + void addFile(String relative) { + final file = File(path.join(manifestDir, relative)); + if (file.existsSync()) { + files.add(file); + } + } + + addFile('Cargo.toml'); + addFile('Cargo.lock'); + addFile('build.rs'); + addFile('cargokit.yaml'); + return files; + } + + final String manifestDir; + final String? tempStorage; +} diff --git a/rust_builder/cargokit/build_tool/lib/src/environment.dart b/rust_builder/cargokit/build_tool/lib/src/environment.dart new file mode 100644 index 0000000..996483a --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/environment.dart @@ -0,0 +1,68 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +extension on String { + String resolveSymlink() => File(this).resolveSymbolicLinksSync(); +} + +class Environment { + /// Current build configuration (debug or release). + static String get configuration => + _getEnv("CARGOKIT_CONFIGURATION").toLowerCase(); + + static bool get isDebug => configuration == 'debug'; + static bool get isRelease => configuration == 'release'; + + /// Temporary directory where Rust build artifacts are placed. + static String get targetTempDir => _getEnv("CARGOKIT_TARGET_TEMP_DIR"); + + /// Final output directory where the build artifacts are placed. + static String get outputDir => _getEnvPath('CARGOKIT_OUTPUT_DIR'); + + /// Path to the crate manifest (containing Cargo.toml). + static String get manifestDir => _getEnvPath('CARGOKIT_MANIFEST_DIR'); + + /// Directory inside root project. Not necessarily root folder. Symlinks are + /// not resolved on purpose. + static String get rootProjectDir => _getEnv('CARGOKIT_ROOT_PROJECT_DIR'); + + // Pod + + /// Platform name (macosx, iphoneos, iphonesimulator). + static String get darwinPlatformName => + _getEnv("CARGOKIT_DARWIN_PLATFORM_NAME"); + + /// List of architectures to build for (arm64, armv7, x86_64). + static List get darwinArchs => + _getEnv("CARGOKIT_DARWIN_ARCHS").split(' '); + + // Gradle + static String get minSdkVersion => _getEnv("CARGOKIT_MIN_SDK_VERSION"); + static String get ndkVersion => _getEnv("CARGOKIT_NDK_VERSION"); + static String get sdkPath => _getEnvPath("CARGOKIT_SDK_DIR"); + static String get javaHome => _getEnvPath("CARGOKIT_JAVA_HOME"); + static List get targetPlatforms => + _getEnv("CARGOKIT_TARGET_PLATFORMS").split(','); + + // CMAKE + static String get targetPlatform => _getEnv("CARGOKIT_TARGET_PLATFORM"); + + static String _getEnv(String key) { + final res = Platform.environment[key]; + if (res == null) { + throw Exception("Missing environment variable $key"); + } + return res; + } + + static String _getEnvPath(String key) { + final res = _getEnv(key); + if (Directory(res).existsSync()) { + return res.resolveSymlink(); + } else { + return res; + } + } +} diff --git a/rust_builder/cargokit/build_tool/lib/src/logging.dart b/rust_builder/cargokit/build_tool/lib/src/logging.dart new file mode 100644 index 0000000..5edd4fd --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/logging.dart @@ -0,0 +1,52 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:logging/logging.dart'; + +const String kSeparator = "--"; +const String kDoubleSeparator = "=="; + +bool _lastMessageWasSeparator = false; + +void _log(LogRecord rec) { + final prefix = '${rec.level.name}: '; + final out = rec.level == Level.SEVERE ? stderr : stdout; + if (rec.message == kSeparator) { + if (!_lastMessageWasSeparator) { + out.write(prefix); + out.writeln('-' * 80); + _lastMessageWasSeparator = true; + } + return; + } else if (rec.message == kDoubleSeparator) { + out.write(prefix); + out.writeln('=' * 80); + _lastMessageWasSeparator = true; + return; + } + out.write(prefix); + out.writeln(rec.message); + _lastMessageWasSeparator = false; +} + +void initLogging() { + Logger.root.level = Level.INFO; + Logger.root.onRecord.listen((LogRecord rec) { + final lines = rec.message.split('\n'); + for (final line in lines) { + if (line.isNotEmpty || lines.length == 1 || line != lines.last) { + _log(LogRecord( + rec.level, + line, + rec.loggerName, + )); + } + } + }); +} + +void enableVerboseLogging() { + Logger.root.level = Level.ALL; +} diff --git a/rust_builder/cargokit/build_tool/lib/src/options.dart b/rust_builder/cargokit/build_tool/lib/src/options.dart new file mode 100644 index 0000000..22aef1d --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/options.dart @@ -0,0 +1,309 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:hex/hex.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; +import 'package:source_span/source_span.dart'; +import 'package:yaml/yaml.dart'; + +import 'builder.dart'; +import 'environment.dart'; +import 'rustup.dart'; + +final _log = Logger('options'); + +/// A class for exceptions that have source span information attached. +class SourceSpanException implements Exception { + // This is a getter so that subclasses can override it. + /// A message describing the exception. + String get message => _message; + final String _message; + + // This is a getter so that subclasses can override it. + /// The span associated with this exception. + /// + /// This may be `null` if the source location can't be determined. + SourceSpan? get span => _span; + final SourceSpan? _span; + + SourceSpanException(this._message, this._span); + + /// Returns a string representation of `this`. + /// + /// [color] may either be a [String], a [bool], or `null`. If it's a string, + /// it indicates an ANSI terminal color escape that should be used to + /// highlight the span's text. If it's `true`, it indicates that the text + /// should be highlighted using the default color. If it's `false` or `null`, + /// it indicates that the text shouldn't be highlighted. + @override + String toString({Object? color}) { + if (span == null) return message; + return 'Error on ${span!.message(message, color: color)}'; + } +} + +enum Toolchain { + stable, + beta, + nightly, +} + +class CargoBuildOptions { + final Toolchain toolchain; + final List flags; + + CargoBuildOptions({ + required this.toolchain, + required this.flags, + }); + + static Toolchain _toolchainFromNode(YamlNode node) { + if (node case YamlScalar(value: String name)) { + final toolchain = + Toolchain.values.firstWhereOrNull((element) => element.name == name); + if (toolchain != null) { + return toolchain; + } + } + throw SourceSpanException( + 'Unknown toolchain. Must be one of ${Toolchain.values.map((e) => e.name)}.', + node.span); + } + + static CargoBuildOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargo options must be a map', node.span); + } + Toolchain toolchain = Toolchain.stable; + List flags = []; + for (final MapEntry(:key, :value) in node.nodes.entries) { + if (key case YamlScalar(value: 'toolchain')) { + toolchain = _toolchainFromNode(value); + } else if (key case YamlScalar(value: 'extra_flags')) { + if (value case YamlList(nodes: List list)) { + if (list.every((element) { + if (element case YamlScalar(value: String _)) { + return true; + } + return false; + })) { + flags = list.map((e) => e.value as String).toList(); + continue; + } + } + throw SourceSpanException( + 'Extra flags must be a list of strings', value.span); + } else { + throw SourceSpanException( + 'Unknown cargo option type. Must be "toolchain" or "extra_flags".', + key.span); + } + } + return CargoBuildOptions(toolchain: toolchain, flags: flags); + } +} + +extension on YamlMap { + /// Map that extracts keys so that we can do map case check on them. + Map get valueMap => + nodes.map((key, value) => MapEntry(key.value, value)); +} + +class PrecompiledBinaries { + final String uriPrefix; + final PublicKey publicKey; + + PrecompiledBinaries({ + required this.uriPrefix, + required this.publicKey, + }); + + static PublicKey _publicKeyFromHex(String key, SourceSpan? span) { + final bytes = HEX.decode(key); + if (bytes.length != 32) { + throw SourceSpanException( + 'Invalid public key. Must be 32 bytes long.', span); + } + return PublicKey(bytes); + } + + static PrecompiledBinaries parse(YamlNode node) { + if (node case YamlMap(valueMap: Map map)) { + if (map + case { + 'url_prefix': YamlNode urlPrefixNode, + 'public_key': YamlNode publicKeyNode, + }) { + final urlPrefix = switch (urlPrefixNode) { + YamlScalar(value: String urlPrefix) => urlPrefix, + _ => throw SourceSpanException( + 'Invalid URL prefix value.', urlPrefixNode.span), + }; + final publicKey = switch (publicKeyNode) { + YamlScalar(value: String publicKey) => + _publicKeyFromHex(publicKey, publicKeyNode.span), + _ => throw SourceSpanException( + 'Invalid public key value.', publicKeyNode.span), + }; + return PrecompiledBinaries( + uriPrefix: urlPrefix, + publicKey: publicKey, + ); + } + } + throw SourceSpanException( + 'Invalid precompiled binaries value. ' + 'Expected Map with "url_prefix" and "public_key".', + node.span); + } +} + +/// Cargokit options specified for Rust crate. +class CargokitCrateOptions { + CargokitCrateOptions({ + this.cargo = const {}, + this.precompiledBinaries, + }); + + final Map cargo; + final PrecompiledBinaries? precompiledBinaries; + + static CargokitCrateOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargokit options must be a map', node.span); + } + final options = {}; + PrecompiledBinaries? precompiledBinaries; + + for (final entry in node.nodes.entries) { + if (entry + case MapEntry( + key: YamlScalar(value: 'cargo'), + value: YamlNode node, + )) { + if (node is! YamlMap) { + throw SourceSpanException('Cargo options must be a map', node.span); + } + for (final MapEntry(:YamlNode key, :value) in node.nodes.entries) { + if (key case YamlScalar(value: String name)) { + final configuration = BuildConfiguration.values + .firstWhereOrNull((element) => element.name == name); + if (configuration != null) { + options[configuration] = CargoBuildOptions.parse(value); + continue; + } + } + throw SourceSpanException( + 'Unknown build configuration. Must be one of ${BuildConfiguration.values.map((e) => e.name)}.', + key.span); + } + } else if (entry.key case YamlScalar(value: 'precompiled_binaries')) { + precompiledBinaries = PrecompiledBinaries.parse(entry.value); + } else { + throw SourceSpanException( + 'Unknown cargokit option type. Must be "cargo" or "precompiled_binaries".', + entry.key.span); + } + } + return CargokitCrateOptions( + cargo: options, + precompiledBinaries: precompiledBinaries, + ); + } + + static CargokitCrateOptions load({ + required String manifestDir, + }) { + final uri = Uri.file(path.join(manifestDir, "cargokit.yaml")); + final file = File.fromUri(uri); + if (file.existsSync()) { + final contents = loadYamlNode(file.readAsStringSync(), sourceUrl: uri); + return parse(contents); + } else { + return CargokitCrateOptions(); + } + } +} + +class CargokitUserOptions { + // When Rustup is installed always build locally unless user opts into + // using precompiled binaries. + static bool defaultUsePrecompiledBinaries() { + return Rustup.executablePath() == null; + } + + CargokitUserOptions({ + required this.usePrecompiledBinaries, + required this.verboseLogging, + }); + + CargokitUserOptions._() + : usePrecompiledBinaries = defaultUsePrecompiledBinaries(), + verboseLogging = false; + + static CargokitUserOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargokit options must be a map', node.span); + } + bool usePrecompiledBinaries = defaultUsePrecompiledBinaries(); + bool verboseLogging = false; + + for (final entry in node.nodes.entries) { + if (entry.key case YamlScalar(value: 'use_precompiled_binaries')) { + if (entry.value case YamlScalar(value: bool value)) { + usePrecompiledBinaries = value; + continue; + } + throw SourceSpanException( + 'Invalid value for "use_precompiled_binaries". Must be a boolean.', + entry.value.span); + } else if (entry.key case YamlScalar(value: 'verbose_logging')) { + if (entry.value case YamlScalar(value: bool value)) { + verboseLogging = value; + continue; + } + throw SourceSpanException( + 'Invalid value for "verbose_logging". Must be a boolean.', + entry.value.span); + } else { + throw SourceSpanException( + 'Unknown cargokit option type. Must be "use_precompiled_binaries" or "verbose_logging".', + entry.key.span); + } + } + return CargokitUserOptions( + usePrecompiledBinaries: usePrecompiledBinaries, + verboseLogging: verboseLogging, + ); + } + + static CargokitUserOptions load() { + String fileName = "cargokit_options.yaml"; + var userProjectDir = Directory(Environment.rootProjectDir); + + while (userProjectDir.parent.path != userProjectDir.path) { + final configFile = File(path.join(userProjectDir.path, fileName)); + if (configFile.existsSync()) { + final contents = loadYamlNode( + configFile.readAsStringSync(), + sourceUrl: configFile.uri, + ); + final res = parse(contents); + if (res.verboseLogging) { + _log.info('Found user options file at ${configFile.path}'); + } + return res; + } + userProjectDir = userProjectDir.parent; + } + return CargokitUserOptions._(); + } + + final bool usePrecompiledBinaries; + final bool verboseLogging; +} diff --git a/rust_builder/cargokit/build_tool/lib/src/precompile_binaries.dart b/rust_builder/cargokit/build_tool/lib/src/precompile_binaries.dart new file mode 100644 index 0000000..c27f419 --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/precompile_binaries.dart @@ -0,0 +1,202 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:github/github.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'cargo.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'rustup.dart'; +import 'target.dart'; + +final _log = Logger('precompile_binaries'); + +class PrecompileBinaries { + PrecompileBinaries({ + required this.privateKey, + required this.githubToken, + required this.repositorySlug, + required this.manifestDir, + required this.targets, + this.androidSdkLocation, + this.androidNdkVersion, + this.androidMinSdkVersion, + this.tempDir, + }); + + final PrivateKey privateKey; + final String githubToken; + final RepositorySlug repositorySlug; + final String manifestDir; + final List targets; + final String? androidSdkLocation; + final String? androidNdkVersion; + final int? androidMinSdkVersion; + final String? tempDir; + + static String fileName(Target target, String name) { + return '${target.rust}_$name'; + } + + static String signatureFileName(Target target, String name) { + return '${target.rust}_$name.sig'; + } + + Future run() async { + final crateInfo = CrateInfo.load(manifestDir); + + final targets = List.of(this.targets); + if (targets.isEmpty) { + targets.addAll([ + ...Target.buildableTargets(), + if (androidSdkLocation != null) ...Target.androidTargets(), + ]); + } + + _log.info('Precompiling binaries for $targets'); + + final hash = CrateHash.compute(manifestDir); + _log.info('Computed crate hash: $hash'); + + final String tagName = 'precompiled_$hash'; + + final github = GitHub(auth: Authentication.withToken(githubToken)); + final repo = github.repositories; + final release = await _getOrCreateRelease( + repo: repo, + tagName: tagName, + packageName: crateInfo.packageName, + hash: hash, + ); + + final tempDir = this.tempDir != null + ? Directory(this.tempDir!) + : Directory.systemTemp.createTempSync('precompiled_'); + + tempDir.createSync(recursive: true); + + final crateOptions = CargokitCrateOptions.load( + manifestDir: manifestDir, + ); + + final buildEnvironment = BuildEnvironment( + configuration: BuildConfiguration.release, + crateOptions: crateOptions, + targetTempDir: tempDir.path, + manifestDir: manifestDir, + crateInfo: crateInfo, + isAndroid: androidSdkLocation != null, + androidSdkPath: androidSdkLocation, + androidNdkVersion: androidNdkVersion, + androidMinSdkVersion: androidMinSdkVersion, + ); + + final rustup = Rustup(); + + for (final target in targets) { + final artifactNames = getArtifactNames( + target: target, + libraryName: crateInfo.packageName, + remote: true, + ); + + if (artifactNames.every((name) { + final fileName = PrecompileBinaries.fileName(target, name); + return (release.assets ?? []).any((e) => e.name == fileName); + })) { + _log.info("All artifacts for $target already exist - skipping"); + continue; + } + + _log.info('Building for $target'); + + final builder = + RustBuilder(target: target, environment: buildEnvironment); + builder.prepare(rustup); + final res = await builder.build(); + + final assets = []; + for (final name in artifactNames) { + final file = File(path.join(res, name)); + if (!file.existsSync()) { + throw Exception('Missing artifact: ${file.path}'); + } + + final data = file.readAsBytesSync(); + final create = CreateReleaseAsset( + name: PrecompileBinaries.fileName(target, name), + contentType: "application/octet-stream", + assetData: data, + ); + final signature = sign(privateKey, data); + final signatureCreate = CreateReleaseAsset( + name: signatureFileName(target, name), + contentType: "application/octet-stream", + assetData: signature, + ); + bool verified = verify(public(privateKey), data, signature); + if (!verified) { + throw Exception('Signature verification failed'); + } + assets.add(create); + assets.add(signatureCreate); + } + _log.info('Uploading assets: ${assets.map((e) => e.name)}'); + for (final asset in assets) { + // This seems to be failing on CI so do it one by one + int retryCount = 0; + while (true) { + try { + await repo.uploadReleaseAssets(release, [asset]); + break; + } on Exception catch (e) { + if (retryCount == 10) { + rethrow; + } + ++retryCount; + _log.shout( + 'Upload failed (attempt $retryCount, will retry): ${e.toString()}'); + await Future.delayed(Duration(seconds: 2)); + } + } + } + } + + _log.info('Cleaning up'); + tempDir.deleteSync(recursive: true); + } + + Future _getOrCreateRelease({ + required RepositoriesService repo, + required String tagName, + required String packageName, + required String hash, + }) async { + Release release; + try { + _log.info('Fetching release $tagName'); + release = await repo.getReleaseByTagName(repositorySlug, tagName); + } on ReleaseNotFound { + _log.info('Release not found - creating release $tagName'); + release = await repo.createRelease( + repositorySlug, + CreateRelease.from( + tagName: tagName, + name: 'Precompiled binaries ${hash.substring(0, 8)}', + targetCommitish: null, + isDraft: false, + isPrerelease: false, + body: 'Precompiled binaries for crate $packageName, ' + 'crate hash $hash.', + )); + } + return release; + } +} diff --git a/rust_builder/cargokit/build_tool/lib/src/rustup.dart b/rust_builder/cargokit/build_tool/lib/src/rustup.dart new file mode 100644 index 0000000..0ac8d08 --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/rustup.dart @@ -0,0 +1,136 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as path; + +import 'util.dart'; + +class _Toolchain { + _Toolchain( + this.name, + this.targets, + ); + + final String name; + final List targets; +} + +class Rustup { + List? installedTargets(String toolchain) { + final targets = _installedTargets(toolchain); + return targets != null ? List.unmodifiable(targets) : null; + } + + void installToolchain(String toolchain) { + log.info("Installing Rust toolchain: $toolchain"); + runCommand("rustup", ['toolchain', 'install', toolchain]); + _installedToolchains + .add(_Toolchain(toolchain, _getInstalledTargets(toolchain))); + } + + void installTarget( + String target, { + required String toolchain, + }) { + log.info("Installing Rust target: $target"); + runCommand("rustup", [ + 'target', + 'add', + '--toolchain', + toolchain, + target, + ]); + _installedTargets(toolchain)?.add(target); + } + + final List<_Toolchain> _installedToolchains; + + Rustup() : _installedToolchains = _getInstalledToolchains(); + + List? _installedTargets(String toolchain) => _installedToolchains + .firstWhereOrNull( + (e) => e.name == toolchain || e.name.startsWith('$toolchain-')) + ?.targets; + + static List<_Toolchain> _getInstalledToolchains() { + String extractToolchainName(String line) { + // ignore (default) after toolchain name + final parts = line.split(' '); + return parts[0]; + } + + final res = runCommand("rustup", ['toolchain', 'list']); + + // To list all non-custom toolchains, we need to filter out lines that + // don't start with "stable", "beta", or "nightly". + Pattern nonCustom = RegExp(r"^(stable|beta|nightly)"); + final lines = res.stdout + .toString() + .split('\n') + .where((e) => e.isNotEmpty && e.startsWith(nonCustom)) + .map(extractToolchainName) + .toList(growable: true); + + return lines + .map( + (name) => _Toolchain( + name, + _getInstalledTargets(name), + ), + ) + .toList(growable: true); + } + + static List _getInstalledTargets(String toolchain) { + final res = runCommand("rustup", [ + 'target', + 'list', + '--toolchain', + toolchain, + '--installed', + ]); + final lines = res.stdout + .toString() + .split('\n') + .where((e) => e.isNotEmpty) + .toList(growable: true); + return lines; + } + + bool _didInstallRustSrcForNightly = false; + + void installRustSrcForNightly() { + if (_didInstallRustSrcForNightly) { + return; + } + // Useful for -Z build-std + runCommand( + "rustup", + ['component', 'add', 'rust-src', '--toolchain', 'nightly'], + ); + _didInstallRustSrcForNightly = true; + } + + static String? executablePath() { + final envPath = Platform.environment['PATH']; + final envPathSeparator = Platform.isWindows ? ';' : ':'; + final home = Platform.isWindows + ? Platform.environment['USERPROFILE'] + : Platform.environment['HOME']; + final paths = [ + if (home != null) path.join(home, '.cargo', 'bin'), + if (envPath != null) ...envPath.split(envPathSeparator), + ]; + for (final p in paths) { + final rustup = Platform.isWindows ? 'rustup.exe' : 'rustup'; + final rustupPath = path.join(p, rustup); + if (File(rustupPath).existsSync()) { + return rustupPath; + } + } + return null; + } +} diff --git a/rust_builder/cargokit/build_tool/lib/src/target.dart b/rust_builder/cargokit/build_tool/lib/src/target.dart new file mode 100644 index 0000000..6fbc58b --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/target.dart @@ -0,0 +1,140 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; + +import 'util.dart'; + +class Target { + Target({ + required this.rust, + this.flutter, + this.android, + this.androidMinSdkVersion, + this.darwinPlatform, + this.darwinArch, + }); + + static final all = [ + Target( + rust: 'armv7-linux-androideabi', + flutter: 'android-arm', + android: 'armeabi-v7a', + androidMinSdkVersion: 16, + ), + Target( + rust: 'aarch64-linux-android', + flutter: 'android-arm64', + android: 'arm64-v8a', + androidMinSdkVersion: 21, + ), + Target( + rust: 'i686-linux-android', + flutter: 'android-x86', + android: 'x86', + androidMinSdkVersion: 16, + ), + Target( + rust: 'x86_64-linux-android', + flutter: 'android-x64', + android: 'x86_64', + androidMinSdkVersion: 21, + ), + Target( + rust: 'x86_64-pc-windows-msvc', + flutter: 'windows-x64', + ), + Target( + rust: 'x86_64-unknown-linux-gnu', + flutter: 'linux-x64', + ), + Target( + rust: 'aarch64-unknown-linux-gnu', + flutter: 'linux-arm64', + ), + Target( + rust: 'x86_64-apple-darwin', + darwinPlatform: 'macosx', + darwinArch: 'x86_64', + ), + Target( + rust: 'aarch64-apple-darwin', + darwinPlatform: 'macosx', + darwinArch: 'arm64', + ), + Target( + rust: 'aarch64-apple-ios', + darwinPlatform: 'iphoneos', + darwinArch: 'arm64', + ), + Target( + rust: 'aarch64-apple-ios-sim', + darwinPlatform: 'iphonesimulator', + darwinArch: 'arm64', + ), + Target( + rust: 'x86_64-apple-ios', + darwinPlatform: 'iphonesimulator', + darwinArch: 'x86_64', + ), + ]; + + static Target? forFlutterName(String flutterName) { + return all.firstWhereOrNull((element) => element.flutter == flutterName); + } + + static Target? forDarwin({ + required String platformName, + required String darwinAarch, + }) { + return all.firstWhereOrNull((element) => // + element.darwinPlatform == platformName && + element.darwinArch == darwinAarch); + } + + static Target? forRustTriple(String triple) { + return all.firstWhereOrNull((element) => element.rust == triple); + } + + static List androidTargets() { + return all + .where((element) => element.android != null) + .toList(growable: false); + } + + /// Returns buildable targets on current host platform ignoring Android targets. + static List buildableTargets() { + if (Platform.isLinux) { + // Right now we don't support cross-compiling on Linux. So we just return + // the host target. + final arch = runCommand('arch', []).stdout as String; + if (arch.trim() == 'aarch64') { + return [Target.forRustTriple('aarch64-unknown-linux-gnu')!]; + } else { + return [Target.forRustTriple('x86_64-unknown-linux-gnu')!]; + } + } + return all.where((target) { + if (Platform.isWindows) { + return target.rust.contains('-windows-'); + } else if (Platform.isMacOS) { + return target.darwinPlatform != null; + } + return false; + }).toList(growable: false); + } + + @override + String toString() { + return rust; + } + + final String? flutter; + final String rust; + final String? android; + final int? androidMinSdkVersion; + final String? darwinPlatform; + final String? darwinArch; +} diff --git a/rust_builder/cargokit/build_tool/lib/src/util.dart b/rust_builder/cargokit/build_tool/lib/src/util.dart new file mode 100644 index 0000000..8bb6a87 --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/util.dart @@ -0,0 +1,172 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:convert'; +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'logging.dart'; +import 'rustup.dart'; + +final log = Logger("process"); + +class CommandFailedException implements Exception { + final String executable; + final List arguments; + final ProcessResult result; + + CommandFailedException({ + required this.executable, + required this.arguments, + required this.result, + }); + + @override + String toString() { + final stdout = result.stdout.toString().trim(); + final stderr = result.stderr.toString().trim(); + return [ + "External Command: $executable ${arguments.map((e) => '"$e"').join(' ')}", + "Returned Exit Code: ${result.exitCode}", + kSeparator, + "STDOUT:", + if (stdout.isNotEmpty) stdout, + kSeparator, + "STDERR:", + if (stderr.isNotEmpty) stderr, + ].join('\n'); + } +} + +class TestRunCommandArgs { + final String executable; + final List arguments; + final String? workingDirectory; + final Map? environment; + final bool includeParentEnvironment; + final bool runInShell; + final Encoding? stdoutEncoding; + final Encoding? stderrEncoding; + + TestRunCommandArgs({ + required this.executable, + required this.arguments, + this.workingDirectory, + this.environment, + this.includeParentEnvironment = true, + this.runInShell = false, + this.stdoutEncoding, + this.stderrEncoding, + }); +} + +class TestRunCommandResult { + TestRunCommandResult({ + this.pid = 1, + this.exitCode = 0, + this.stdout = '', + this.stderr = '', + }); + + final int pid; + final int exitCode; + final String stdout; + final String stderr; +} + +TestRunCommandResult Function(TestRunCommandArgs args)? testRunCommandOverride; + +ProcessResult runCommand( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, +}) { + if (testRunCommandOverride != null) { + final result = testRunCommandOverride!(TestRunCommandArgs( + executable: executable, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding, + )); + return ProcessResult( + result.pid, + result.exitCode, + result.stdout, + result.stderr, + ); + } + log.finer('Running command $executable ${arguments.join(' ')}'); + final res = Process.runSync( + _resolveExecutable(executable), + arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stderrEncoding: stderrEncoding, + stdoutEncoding: stdoutEncoding, + ); + if (res.exitCode != 0) { + throw CommandFailedException( + executable: executable, + arguments: arguments, + result: res, + ); + } else { + return res; + } +} + +class RustupNotFoundException implements Exception { + @override + String toString() { + return [ + ' ', + 'rustup not found in PATH.', + ' ', + 'Maybe you need to install Rust? It only takes a minute:', + ' ', + if (Platform.isWindows) 'https://www.rust-lang.org/tools/install', + if (hasHomebrewRustInPath()) ...[ + '\$ brew unlink rust # Unlink homebrew Rust from PATH', + ], + if (!Platform.isWindows) + "\$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh", + ' ', + ].join('\n'); + } + + static bool hasHomebrewRustInPath() { + if (!Platform.isMacOS) { + return false; + } + final envPath = Platform.environment['PATH'] ?? ''; + final paths = envPath.split(':'); + return paths.any((p) { + return p.contains('homebrew') && File(path.join(p, 'rustc')).existsSync(); + }); + } +} + +String _resolveExecutable(String executable) { + if (executable == 'rustup') { + final resolved = Rustup.executablePath(); + if (resolved != null) { + return resolved; + } + throw RustupNotFoundException(); + } else { + return executable; + } +} diff --git a/rust_builder/cargokit/build_tool/lib/src/verify_binaries.dart b/rust_builder/cargokit/build_tool/lib/src/verify_binaries.dart new file mode 100644 index 0000000..2366b57 --- /dev/null +++ b/rust_builder/cargokit/build_tool/lib/src/verify_binaries.dart @@ -0,0 +1,84 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:http/http.dart'; + +import 'artifacts_provider.dart'; +import 'cargo.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'target.dart'; + +class VerifyBinaries { + VerifyBinaries({ + required this.manifestDir, + }); + + final String manifestDir; + + Future run() async { + final crateInfo = CrateInfo.load(manifestDir); + + final config = CargokitCrateOptions.load(manifestDir: manifestDir); + final precompiledBinaries = config.precompiledBinaries; + if (precompiledBinaries == null) { + stdout.writeln('Crate does not support precompiled binaries.'); + } else { + final crateHash = CrateHash.compute(manifestDir); + stdout.writeln('Crate hash: $crateHash'); + + for (final target in Target.all) { + final message = 'Checking ${target.rust}...'; + stdout.write(message.padRight(40)); + stdout.flush(); + + final artifacts = getArtifactNames( + target: target, + libraryName: crateInfo.packageName, + remote: true, + ); + + final prefix = precompiledBinaries.uriPrefix; + + bool ok = true; + + for (final artifact in artifacts) { + final fileName = PrecompileBinaries.fileName(target, artifact); + final signatureFileName = + PrecompileBinaries.signatureFileName(target, artifact); + + final url = Uri.parse('$prefix$crateHash/$fileName'); + final signatureUrl = + Uri.parse('$prefix$crateHash/$signatureFileName'); + + final signature = await get(signatureUrl); + if (signature.statusCode != 200) { + stdout.writeln('MISSING'); + ok = false; + break; + } + final asset = await get(url); + if (asset.statusCode != 200) { + stdout.writeln('MISSING'); + ok = false; + break; + } + + if (!verify(precompiledBinaries.publicKey, asset.bodyBytes, + signature.bodyBytes)) { + stdout.writeln('INVALID SIGNATURE'); + ok = false; + } + } + + if (ok) { + stdout.writeln('OK'); + } + } + } + } +} diff --git a/rust_builder/cargokit/build_tool/pubspec.lock b/rust_builder/cargokit/build_tool/pubspec.lock new file mode 100644 index 0000000..343bdd3 --- /dev/null +++ b/rust_builder/cargokit/build_tool/pubspec.lock @@ -0,0 +1,453 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + url: "https://pub.dev" + source: hosted + version: "64.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + url: "https://pub.dev" + source: hosted + version: "6.2.0" + args: + dependency: "direct main" + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + convert: + dependency: "direct main" + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" + url: "https://pub.dev" + source: hosted + version: "1.6.3" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + ed25519_edwards: + dependency: "direct main" + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + file: + dependency: transitive + description: + name: file + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + github: + dependency: "direct main" + description: + name: github + sha256: "9966bc13bf612342e916b0a343e95e5f046c88f602a14476440e9b75d2295411" + url: "https://pub.dev" + source: hosted + version: "9.17.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + hex: + dependency: "direct main" + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + http: + dependency: "direct main" + description: + name: http + sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" + source: hosted + version: "4.8.1" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + logging: + dependency: "direct main" + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + url: "https://pub.dev" + source: hosted + version: "0.12.16" + meta: + dependency: transitive + description: + name: meta + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + mime: + dependency: transitive + description: + name: mime + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + url: "https://pub.dev" + source: hosted + version: "1.0.4" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: "direct main" + description: + name: path + sha256: "2ad4cddff7f5cc0e2d13069f2a3f7a73ca18f66abd6f5ecf215219cdb3638edb" + url: "https://pub.dev" + source: hosted + version: "1.8.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + url: "https://pub.dev" + source: hosted + version: "5.4.0" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: "direct main" + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: "9b0dd8e36af4a5b1569029949d50a52cb2a2a2fdaa20cebb96e6603b9ae241f9" + url: "https://pub.dev" + source: hosted + version: "1.24.6" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + test_core: + dependency: transitive + description: + name: test_core + sha256: "4bef837e56375537055fdbbbf6dd458b1859881f4c7e6da936158f77d61ab265" + url: "https://pub.dev" + source: hosted + version: "0.5.6" + toml: + dependency: "direct main" + description: + name: toml + sha256: "157c5dca5160fced243f3ce984117f729c788bb5e475504f3dbcda881accee44" + url: "https://pub.dev" + source: hosted + version: "0.14.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + version: + dependency: "direct main" + description: + name: version + sha256: "2307e23a45b43f96469eeab946208ed63293e8afca9c28cd8b5241ff31c55f55" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0fae432c85c4ea880b33b497d32824b97795b04cdaa74d270219572a1f50268d" + url: "https://pub.dev" + source: hosted + version: "11.9.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + yaml: + dependency: "direct main" + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.0.0 <4.0.0" diff --git a/rust_builder/cargokit/build_tool/pubspec.yaml b/rust_builder/cargokit/build_tool/pubspec.yaml new file mode 100644 index 0000000..18c61e3 --- /dev/null +++ b/rust_builder/cargokit/build_tool/pubspec.yaml @@ -0,0 +1,33 @@ +# This is copied from Cargokit (which is the official way to use it currently) +# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +name: build_tool +description: Cargokit build_tool. Facilitates the build of Rust crate during Flutter application build. +publish_to: none +version: 1.0.0 + +environment: + sdk: ">=3.0.0 <4.0.0" + +# Add regular dependencies here. +dependencies: + # these are pinned on purpose because the bundle_tool_runner doesn't have + # pubspec.lock. See run_build_tool.sh + logging: 1.2.0 + path: 1.8.0 + version: 3.0.0 + collection: 1.18.0 + ed25519_edwards: 0.3.1 + hex: 0.2.0 + yaml: 3.1.2 + source_span: 1.10.0 + github: 9.17.0 + args: 2.4.2 + crypto: 3.0.3 + convert: 3.1.1 + http: 1.1.0 + toml: 0.14.0 + +dev_dependencies: + lints: ^2.1.0 + test: ^1.24.0 diff --git a/rust_builder/cargokit/cmake/cargokit.cmake b/rust_builder/cargokit/cmake/cargokit.cmake new file mode 100644 index 0000000..ddd05df --- /dev/null +++ b/rust_builder/cargokit/cmake/cargokit.cmake @@ -0,0 +1,99 @@ +SET(cargokit_cmake_root "${CMAKE_CURRENT_LIST_DIR}/..") + +# Workaround for https://github.com/dart-lang/pub/issues/4010 +get_filename_component(cargokit_cmake_root "${cargokit_cmake_root}" REALPATH) + +if(WIN32) + # REALPATH does not properly resolve symlinks on windows :-/ + execute_process(COMMAND powershell -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_LIST_DIR}/resolve_symlinks.ps1" "${cargokit_cmake_root}" OUTPUT_VARIABLE cargokit_cmake_root OUTPUT_STRIP_TRAILING_WHITESPACE) +endif() + +# Arguments +# - target: CMAKE target to which rust library is linked +# - manifest_dir: relative path from current folder to directory containing cargo manifest +# - lib_name: cargo package name +# - any_symbol_name: name of any exported symbol from the library. +# used on windows to force linking with library. +function(apply_cargokit target manifest_dir lib_name any_symbol_name) + + set(CARGOKIT_LIB_NAME "${lib_name}") + set(CARGOKIT_LIB_FULL_NAME "${CMAKE_SHARED_MODULE_PREFIX}${CARGOKIT_LIB_NAME}${CMAKE_SHARED_MODULE_SUFFIX}") + if (CMAKE_CONFIGURATION_TYPES) + set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/$") + set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/$/${CARGOKIT_LIB_FULL_NAME}") + else() + set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}") + set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/${CARGOKIT_LIB_FULL_NAME}") + endif() + set(CARGOKIT_TEMP_DIR "${CMAKE_CURRENT_BINARY_DIR}/cargokit_build") + + if (FLUTTER_TARGET_PLATFORM) + set(CARGOKIT_TARGET_PLATFORM "${FLUTTER_TARGET_PLATFORM}") + else() + set(CARGOKIT_TARGET_PLATFORM "windows-x64") + endif() + + set(CARGOKIT_ENV + "CARGOKIT_CMAKE=${CMAKE_COMMAND}" + "CARGOKIT_CONFIGURATION=$" + "CARGOKIT_MANIFEST_DIR=${CMAKE_CURRENT_SOURCE_DIR}/${manifest_dir}" + "CARGOKIT_TARGET_TEMP_DIR=${CARGOKIT_TEMP_DIR}" + "CARGOKIT_OUTPUT_DIR=${CARGOKIT_OUTPUT_DIR}" + "CARGOKIT_TARGET_PLATFORM=${CARGOKIT_TARGET_PLATFORM}" + "CARGOKIT_TOOL_TEMP_DIR=${CARGOKIT_TEMP_DIR}/tool" + "CARGOKIT_ROOT_PROJECT_DIR=${CMAKE_SOURCE_DIR}" + ) + + if (WIN32) + set(SCRIPT_EXTENSION ".cmd") + set(IMPORT_LIB_EXTENSION ".lib") + else() + set(SCRIPT_EXTENSION ".sh") + set(IMPORT_LIB_EXTENSION "") + execute_process(COMMAND chmod +x "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}") + endif() + + # Using generators in custom command is only supported in CMake 3.20+ + if (CMAKE_CONFIGURATION_TYPES AND ${CMAKE_VERSION} VERSION_LESS "3.20.0") + foreach(CONFIG IN LISTS CMAKE_CONFIGURATION_TYPES) + add_custom_command( + OUTPUT + "${CMAKE_CURRENT_BINARY_DIR}/${CONFIG}/${CARGOKIT_LIB_FULL_NAME}" + "${CMAKE_CURRENT_BINARY_DIR}/_phony_" + COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV} + "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake + VERBATIM + ) + endforeach() + else() + add_custom_command( + OUTPUT + ${OUTPUT_LIB} + "${CMAKE_CURRENT_BINARY_DIR}/_phony_" + COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV} + "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake + VERBATIM + ) + endif() + + + set_source_files_properties("${CMAKE_CURRENT_BINARY_DIR}/_phony_" PROPERTIES SYMBOLIC TRUE) + + if (TARGET ${target}) + # If we have actual cmake target provided create target and make existing + # target depend on it + add_custom_target("${target}_cargokit" DEPENDS ${OUTPUT_LIB}) + add_dependencies("${target}" "${target}_cargokit") + target_link_libraries("${target}" PRIVATE "${OUTPUT_LIB}${IMPORT_LIB_EXTENSION}") + if(WIN32) + target_link_options(${target} PRIVATE "/INCLUDE:${any_symbol_name}") + endif() + else() + # Otherwise (FFI) just use ALL to force building always + add_custom_target("${target}_cargokit" ALL DEPENDS ${OUTPUT_LIB}) + endif() + + # Allow adding the output library to plugin bundled libraries + set("${target}_cargokit_lib" ${OUTPUT_LIB} PARENT_SCOPE) + +endfunction() diff --git a/rust_builder/cargokit/cmake/resolve_symlinks.ps1 b/rust_builder/cargokit/cmake/resolve_symlinks.ps1 new file mode 100644 index 0000000..2ac593a --- /dev/null +++ b/rust_builder/cargokit/cmake/resolve_symlinks.ps1 @@ -0,0 +1,34 @@ +function Resolve-Symlinks { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [string] $Path + ) + + [string] $separator = '/' + [string[]] $parts = $Path.Split($separator) + + [string] $realPath = '' + foreach ($part in $parts) { + if ($realPath -and !$realPath.EndsWith($separator)) { + $realPath += $separator + } + + $realPath += $part.Replace('\', '/') + + # The slash is important when using Get-Item on Drive letters in pwsh. + if (-not($realPath.Contains($separator)) -and $realPath.EndsWith(':')) { + $realPath += '/' + } + + $item = Get-Item $realPath + if ($item.LinkTarget) { + $realPath = $item.LinkTarget.Replace('\', '/') + } + } + $realPath +} + +$path = Resolve-Symlinks -Path $args[0] +Write-Host $path diff --git a/rust_builder/cargokit/gradle/plugin.gradle b/rust_builder/cargokit/gradle/plugin.gradle new file mode 100644 index 0000000..4af35ee --- /dev/null +++ b/rust_builder/cargokit/gradle/plugin.gradle @@ -0,0 +1,179 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import java.nio.file.Paths +import org.apache.tools.ant.taskdefs.condition.Os + +CargoKitPlugin.file = buildscript.sourceFile + +apply plugin: CargoKitPlugin + +class CargoKitExtension { + String manifestDir; // Relative path to folder containing Cargo.toml + String libname; // Library name within Cargo.toml. Must be a cdylib +} + +abstract class CargoKitBuildTask extends DefaultTask { + + @Input + String buildMode + + @Input + String buildDir + + @Input + String outputDir + + @Input + String ndkVersion + + @Input + String sdkDirectory + + @Input + int compileSdkVersion; + + @Input + int minSdkVersion; + + @Input + String pluginFile + + @Input + List targetPlatforms + + @TaskAction + def build() { + if (project.cargokit.manifestDir == null) { + throw new GradleException("Property 'manifestDir' must be set on cargokit extension"); + } + + if (project.cargokit.libname == null) { + throw new GradleException("Property 'libname' must be set on cargokit extension"); + } + + def executableName = Os.isFamily(Os.FAMILY_WINDOWS) ? "run_build_tool.cmd" : "run_build_tool.sh" + def path = Paths.get(new File(pluginFile).parent, "..", executableName); + + def manifestDir = Paths.get(project.buildscript.sourceFile.parent, project.cargokit.manifestDir) + + def rootProjectDir = project.rootProject.projectDir + + if (!Os.isFamily(Os.FAMILY_WINDOWS)) { + project.exec { + commandLine 'chmod', '+x', path + } + } + + project.exec { + executable path + args "build-gradle" + environment "CARGOKIT_ROOT_PROJECT_DIR", rootProjectDir + environment "CARGOKIT_TOOL_TEMP_DIR", "${buildDir}/build_tool" + environment "CARGOKIT_MANIFEST_DIR", manifestDir + environment "CARGOKIT_CONFIGURATION", buildMode + environment "CARGOKIT_TARGET_TEMP_DIR", buildDir + environment "CARGOKIT_OUTPUT_DIR", outputDir + environment "CARGOKIT_NDK_VERSION", ndkVersion + environment "CARGOKIT_SDK_DIR", sdkDirectory + environment "CARGOKIT_COMPILE_SDK_VERSION", compileSdkVersion + environment "CARGOKIT_MIN_SDK_VERSION", minSdkVersion + environment "CARGOKIT_TARGET_PLATFORMS", targetPlatforms.join(",") + environment "CARGOKIT_JAVA_HOME", System.properties['java.home'] + } + } +} + +class CargoKitPlugin implements Plugin { + + static String file; + + private Plugin findFlutterPlugin(Project rootProject) { + _findFlutterPlugin(rootProject.childProjects) + } + + private Plugin _findFlutterPlugin(Map projects) { + for (project in projects) { + for (plugin in project.value.getPlugins()) { + if (plugin.class.name == "com.flutter.gradle.FlutterPlugin") { + return plugin; + } + } + def plugin = _findFlutterPlugin(project.value.childProjects); + if (plugin != null) { + return plugin; + } + } + return null; + } + + @Override + void apply(Project project) { + def plugin = findFlutterPlugin(project.rootProject); + + project.extensions.create("cargokit", CargoKitExtension) + + if (plugin == null) { + print("Flutter plugin not found, CargoKit plugin will not be applied.") + return; + } + + def cargoBuildDir = "${project.buildDir}/build" + + // Determine if the project is an application or library + def isApplication = plugin.project.plugins.hasPlugin('com.android.application') + def variants = isApplication ? plugin.project.android.applicationVariants : plugin.project.android.libraryVariants + + variants.all { variant -> + + final buildType = variant.buildType.name + + def cargoOutputDir = "${project.buildDir}/jniLibs/${buildType}"; + def jniLibs = project.android.sourceSets.maybeCreate(buildType).jniLibs; + jniLibs.srcDir(new File(cargoOutputDir)) + + def platforms = com.flutter.gradle.FlutterPluginUtils.getTargetPlatforms(project).collect() + + // Same thing addFlutterDependencies does in flutter.gradle + if (buildType == "debug") { + platforms.add("android-x86") + platforms.add("android-x64") + } + + // The task name depends on plugin properties, which are not available + // at this point + project.getGradle().afterProject { + def taskName = "cargokitCargoBuild${project.cargokit.libname.capitalize()}${buildType.capitalize()}"; + + if (project.tasks.findByName(taskName)) { + return + } + + if (plugin.project.android.ndkVersion == null) { + throw new GradleException("Please set 'android.ndkVersion' in 'app/build.gradle'.") + } + + def task = project.tasks.create(taskName, CargoKitBuildTask.class) { + buildMode = variant.buildType.name + buildDir = cargoBuildDir + outputDir = cargoOutputDir + ndkVersion = plugin.project.android.ndkVersion + sdkDirectory = plugin.project.android.sdkDirectory + minSdkVersion = plugin.project.android.defaultConfig.minSdkVersion.apiLevel as int + compileSdkVersion = plugin.project.android.compileSdkVersion.substring(8) as int + targetPlatforms = platforms + pluginFile = CargoKitPlugin.file + } + def onTask = { newTask -> + if (newTask.name == "merge${buildType.capitalize()}NativeLibs") { + newTask.dependsOn task + // Fix gradle 7.4.2 not picking up JNI library changes + newTask.outputs.upToDateWhen { false } + } + } + project.tasks.each onTask + project.tasks.whenTaskAdded onTask + } + } + } +} diff --git a/rust_builder/cargokit/run_build_tool.cmd b/rust_builder/cargokit/run_build_tool.cmd new file mode 100755 index 0000000..c45d0aa --- /dev/null +++ b/rust_builder/cargokit/run_build_tool.cmd @@ -0,0 +1,91 @@ +@echo off +setlocal + +setlocal ENABLEDELAYEDEXPANSION + +SET BASEDIR=%~dp0 + +if not exist "%CARGOKIT_TOOL_TEMP_DIR%" ( + mkdir "%CARGOKIT_TOOL_TEMP_DIR%" +) +cd /D "%CARGOKIT_TOOL_TEMP_DIR%" + +SET BUILD_TOOL_PKG_DIR=%BASEDIR%build_tool +SET DART=%FLUTTER_ROOT%\bin\cache\dart-sdk\bin\dart + +set BUILD_TOOL_PKG_DIR_POSIX=%BUILD_TOOL_PKG_DIR:\=/% + +( + echo name: build_tool_runner + echo version: 1.0.0 + echo publish_to: none + echo. + echo environment: + echo sdk: '^>=3.0.0 ^<4.0.0' + echo. + echo dependencies: + echo build_tool: + echo path: %BUILD_TOOL_PKG_DIR_POSIX% +) >pubspec.yaml + +if not exist bin ( + mkdir bin +) + +( + echo import 'package:build_tool/build_tool.dart' as build_tool; + echo void main^(List^ args^) ^{ + echo build_tool.runMain^(args^); + echo ^} +) >bin\build_tool_runner.dart + +SET PRECOMPILED=bin\build_tool_runner.dill + +REM To detect changes in package we compare output of DIR /s (recursive) +set PREV_PACKAGE_INFO=.dart_tool\package_info.prev +set CUR_PACKAGE_INFO=.dart_tool\package_info.cur + +DIR "%BUILD_TOOL_PKG_DIR%" /s > "%CUR_PACKAGE_INFO%_orig" + +REM Last line in dir output is free space on harddrive. That is bound to +REM change between invocation so we need to remove it +( + Set "Line=" + For /F "UseBackQ Delims=" %%A In ("%CUR_PACKAGE_INFO%_orig") Do ( + SetLocal EnableDelayedExpansion + If Defined Line Echo !Line! + EndLocal + Set "Line=%%A") +) >"%CUR_PACKAGE_INFO%" +DEL "%CUR_PACKAGE_INFO%_orig" + +REM Compare current directory listing with previous +FC /B "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" > nul 2>&1 + +If %ERRORLEVEL% neq 0 ( + REM Changed - copy current to previous and remove precompiled kernel + if exist "%PREV_PACKAGE_INFO%" ( + DEL "%PREV_PACKAGE_INFO%" + ) + MOVE /Y "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" + if exist "%PRECOMPILED%" ( + DEL "%PRECOMPILED%" + ) +) + +REM There is no CUR_PACKAGE_INFO it was renamed in previous step to %PREV_PACKAGE_INFO% +REM which means we need to do pub get and precompile +if not exist "%PRECOMPILED%" ( + echo Running pub get in "%cd%" + "%DART%" pub get --no-precompile + "%DART%" compile kernel bin/build_tool_runner.dart +) + +"%DART%" "%PRECOMPILED%" %* + +REM 253 means invalid snapshot version. +If %ERRORLEVEL% equ 253 ( + "%DART%" pub get --no-precompile + "%DART%" compile kernel bin/build_tool_runner.dart + "%DART%" "%PRECOMPILED%" %* +) diff --git a/rust_builder/cargokit/run_build_tool.sh b/rust_builder/cargokit/run_build_tool.sh new file mode 100755 index 0000000..24b0ed8 --- /dev/null +++ b/rust_builder/cargokit/run_build_tool.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +set -e + +BASEDIR=$(dirname "$0") + +mkdir -p "$CARGOKIT_TOOL_TEMP_DIR" + +cd "$CARGOKIT_TOOL_TEMP_DIR" + +# Write a very simple bin package in temp folder that depends on build_tool package +# from Cargokit. This is done to ensure that we don't pollute Cargokit folder +# with .dart_tool contents. + +BUILD_TOOL_PKG_DIR="$BASEDIR/build_tool" + +if [[ -z $FLUTTER_ROOT ]]; then # not defined + DART=dart +else + DART="$FLUTTER_ROOT/bin/cache/dart-sdk/bin/dart" +fi + +cat << EOF > "pubspec.yaml" +name: build_tool_runner +version: 1.0.0 +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + build_tool: + path: "$BUILD_TOOL_PKG_DIR" +EOF + +mkdir -p "bin" + +cat << EOF > "bin/build_tool_runner.dart" +import 'package:build_tool/build_tool.dart' as build_tool; +void main(List args) { + build_tool.runMain(args); +} +EOF + +# Create alias for `shasum` if it does not exist and `sha1sum` exists +if ! [ -x "$(command -v shasum)" ] && [ -x "$(command -v sha1sum)" ]; then + shopt -s expand_aliases + alias shasum="sha1sum" +fi + +# Dart run will not cache any package that has a path dependency, which +# is the case for our build_tool_runner. So instead we precompile the package +# ourselves. +# To invalidate the cached kernel we use the hash of ls -LR of the build_tool +# package directory. This should be good enough, as the build_tool package +# itself is not meant to have any path dependencies. + +if [[ "$OSTYPE" == "darwin"* ]]; then + PACKAGE_HASH=$(ls -lTR "$BUILD_TOOL_PKG_DIR" | shasum) +else + PACKAGE_HASH=$(ls -lR --full-time "$BUILD_TOOL_PKG_DIR" | shasum) +fi + +PACKAGE_HASH_FILE=".package_hash" + +if [ -f "$PACKAGE_HASH_FILE" ]; then + EXISTING_HASH=$(cat "$PACKAGE_HASH_FILE") + if [ "$PACKAGE_HASH" != "$EXISTING_HASH" ]; then + rm "$PACKAGE_HASH_FILE" + fi +fi + +# Run pub get if needed. +if [ ! -f "$PACKAGE_HASH_FILE" ]; then + "$DART" pub get --no-precompile + "$DART" compile kernel bin/build_tool_runner.dart + echo "$PACKAGE_HASH" > "$PACKAGE_HASH_FILE" +fi + +# Rebuild the tool if it was deleted by Android Studio +if [ ! -f "bin/build_tool_runner.dill" ]; then + "$DART" compile kernel bin/build_tool_runner.dart +fi + +set +e + +"$DART" bin/build_tool_runner.dill "$@" + +exit_code=$? + +# 253 means invalid snapshot version. +if [ $exit_code == 253 ]; then + "$DART" pub get --no-precompile + "$DART" compile kernel bin/build_tool_runner.dart + "$DART" bin/build_tool_runner.dill "$@" + exit_code=$? +fi + +exit $exit_code diff --git a/rust_builder/ios/Classes/dummy_file.c b/rust_builder/ios/Classes/dummy_file.c new file mode 100644 index 0000000..e06dab9 --- /dev/null +++ b/rust_builder/ios/Classes/dummy_file.c @@ -0,0 +1 @@ +// This is an empty file to force CocoaPods to create a framework. diff --git a/rust_builder/ios/rust_lib_abawo_bt_app.podspec b/rust_builder/ios/rust_lib_abawo_bt_app.podspec new file mode 100644 index 0000000..8ee2e4f --- /dev/null +++ b/rust_builder/ios/rust_lib_abawo_bt_app.podspec @@ -0,0 +1,45 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint rust_lib_abawo_bt_app.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'rust_lib_abawo_bt_app' + s.version = '0.0.1' + s.summary = 'A new Flutter FFI plugin project.' + s.description = <<-DESC +A new Flutter FFI plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + + # This will ensure the source files in Classes/ are included in the native + # builds of apps using this FFI plugin. Podspec does not support relative + # paths, so Classes contains a forwarder C file that relatively imports + # `../src/*` so that the C sources can be shared among all target platforms. + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '11.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + s.script_phase = { + :name => 'Build Rust library', + # First argument is relative path to the `rust` folder, second is name of rust library + :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../../rust rust_lib_abawo_bt_app', + :execution_position => :before_compile, + :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'], + # Let XCode know that the static library referenced in -force_load below is + # created by this build step. + :output_files => ["${BUILT_PRODUCTS_DIR}/librust_lib_abawo_bt_app.a"], + } + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + # Flutter.framework does not contain a i386 slice. + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-force_load ${BUILT_PRODUCTS_DIR}/librust_lib_abawo_bt_app.a', + } +end \ No newline at end of file diff --git a/rust_builder/linux/CMakeLists.txt b/rust_builder/linux/CMakeLists.txt new file mode 100644 index 0000000..e9fbaa6 --- /dev/null +++ b/rust_builder/linux/CMakeLists.txt @@ -0,0 +1,19 @@ +# The Flutter tooling requires that developers have CMake 3.10 or later +# installed. You should not increase this version, as doing so will cause +# the plugin to fail to compile for some customers of the plugin. +cmake_minimum_required(VERSION 3.10) + +# Project-level configuration. +set(PROJECT_NAME "rust_lib_abawo_bt_app") +project(${PROJECT_NAME} LANGUAGES CXX) + +include("../cargokit/cmake/cargokit.cmake") +apply_cargokit(${PROJECT_NAME} ../../rust rust_lib_abawo_bt_app "") + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(rust_lib_abawo_bt_app_bundled_libraries + "${${PROJECT_NAME}_cargokit_lib}" + PARENT_SCOPE +) diff --git a/rust_builder/macos/Classes/dummy_file.c b/rust_builder/macos/Classes/dummy_file.c new file mode 100644 index 0000000..e06dab9 --- /dev/null +++ b/rust_builder/macos/Classes/dummy_file.c @@ -0,0 +1 @@ +// This is an empty file to force CocoaPods to create a framework. diff --git a/rust_builder/macos/rust_lib_abawo_bt_app.podspec b/rust_builder/macos/rust_lib_abawo_bt_app.podspec new file mode 100644 index 0000000..5a83e44 --- /dev/null +++ b/rust_builder/macos/rust_lib_abawo_bt_app.podspec @@ -0,0 +1,44 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint rust_lib_abawo_bt_app.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'rust_lib_abawo_bt_app' + s.version = '0.0.1' + s.summary = 'A new Flutter FFI plugin project.' + s.description = <<-DESC +A new Flutter FFI plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + + # This will ensure the source files in Classes/ are included in the native + # builds of apps using this FFI plugin. Podspec does not support relative + # paths, so Classes contains a forwarder C file that relatively imports + # `../src/*` so that the C sources can be shared among all target platforms. + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.11' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' + + s.script_phase = { + :name => 'Build Rust library', + # First argument is relative path to the `rust` folder, second is name of rust library + :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../../rust rust_lib_abawo_bt_app', + :execution_position => :before_compile, + :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'], + # Let XCode know that the static library referenced in -force_load below is + # created by this build step. + :output_files => ["${BUILT_PRODUCTS_DIR}/librust_lib_abawo_bt_app.a"], + } + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + # Flutter.framework does not contain a i386 slice. + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-force_load ${BUILT_PRODUCTS_DIR}/librust_lib_abawo_bt_app.a', + } +end \ No newline at end of file diff --git a/rust_builder/pubspec.yaml b/rust_builder/pubspec.yaml new file mode 100644 index 0000000..18996b2 --- /dev/null +++ b/rust_builder/pubspec.yaml @@ -0,0 +1,34 @@ +name: rust_lib_abawo_bt_app +description: "Utility to build Rust code" +version: 0.0.1 +publish_to: none + +environment: + sdk: '>=3.3.0 <4.0.0' + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + ffi: ^2.0.2 + ffigen: ^11.0.0 + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: + plugin: + platforms: + android: + ffiPlugin: true + ios: + ffiPlugin: true + linux: + ffiPlugin: true + macos: + ffiPlugin: true + windows: + ffiPlugin: true diff --git a/rust_builder/windows/.gitignore b/rust_builder/windows/.gitignore new file mode 100644 index 0000000..b3eb2be --- /dev/null +++ b/rust_builder/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/rust_builder/windows/CMakeLists.txt b/rust_builder/windows/CMakeLists.txt new file mode 100644 index 0000000..b96475b --- /dev/null +++ b/rust_builder/windows/CMakeLists.txt @@ -0,0 +1,20 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "rust_lib_abawo_bt_app") +project(${PROJECT_NAME} LANGUAGES CXX) + +include("../cargokit/cmake/cargokit.cmake") +apply_cargokit(${PROJECT_NAME} ../../../../../../rust rust_lib_abawo_bt_app "") + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(rust_lib_abawo_bt_app_bundled_libraries + "${${PROJECT_NAME}_cargokit_lib}" + PARENT_SCOPE +) diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart new file mode 100644 index 0000000..b38629c --- /dev/null +++ b/test_driver/integration_test.dart @@ -0,0 +1,3 @@ +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..9ef9827 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,18 @@ #include "generated_plugin_registrant.h" +#include +#include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + FlutterBluePlusPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterBluePlusPlugin")); + NbUtilsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("NbUtilsPluginCApi")); + Sqlite3FlutterLibsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..249cb58 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,9 +3,14 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus + flutter_blue_plus_winrt + nb_utils + sqlite3_flutter_libs ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + rust_lib_abawo_bt_app ) set(PLUGIN_BUNDLED_LIBRARIES)