ironOS native ios app

feat: redo how multi devices work

dunkirk.sh 23c6c3e4 7a8c7c38

verified
+172 -34
+28 -8
android/app/src/main/java/com/tinkcil/data/ble/BLEManager.kt
··· 10 10 import android.bluetooth.BluetoothManager 11 11 import android.bluetooth.BluetoothProfile 12 12 import android.bluetooth.le.ScanCallback 13 + import android.bluetooth.le.ScanFilter 13 14 import android.bluetooth.le.ScanResult 14 15 import android.bluetooth.le.ScanSettings 16 + import android.os.ParcelUuid 15 17 import android.content.Context 16 18 import com.tinkcil.data.model.CircularBuffer 17 19 import com.tinkcil.data.model.IronOSLiveData ··· 42 44 enum class ConnectionState { 43 45 DISCONNECTED, BLUETOOTH_OFF, SCANNING, CONNECTING, CONNECTED 44 46 } 47 + 48 + data class DiscoveredDevice( 49 + val device: BluetoothDevice, 50 + val name: String 51 + ) 45 52 46 53 @Singleton 47 54 class BLEManager @Inject constructor( ··· 83 90 private val _serialNumber = MutableStateFlow<String?>(null) 84 91 val serialNumber: StateFlow<String?> = _serialNumber.asStateFlow() 85 92 93 + private val _discoveredDevices = MutableStateFlow<List<DiscoveredDevice>>(emptyList()) 94 + val discoveredDevices: StateFlow<List<DiscoveredDevice>> = _discoveredDevices.asStateFlow() 95 + 86 96 private val _lastError = MutableStateFlow<BLEError?>(null) 87 97 val lastError: StateFlow<BLEError?> = _lastError.asStateFlow() 88 98 ··· 172 182 } 173 183 174 184 _connectionState.value = ConnectionState.SCANNING 185 + _discoveredDevices.value = emptyList() 186 + 187 + val scanFilter = ScanFilter.Builder() 188 + .setServiceUuid(ParcelUuid(IronOSUUIDs.BULK_DATA_SERVICE)) 189 + .build() 175 190 176 191 val scanSettings = ScanSettings.Builder() 177 192 .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) ··· 179 194 180 195 val scanCallback = object : ScanCallback() { 181 196 override fun onScanResult(callbackType: Int, result: ScanResult) { 182 - val name = result.device.name ?: return 183 - if (name.startsWith("Pinecil-") || name.startsWith("PrattlePin-")) { 184 - scanner.stopScan(this) 185 - scanTimeoutJob?.cancel() 186 - scope.launch { connectToDevice(result.device, name) } 197 + val name = result.device.name ?: "Unknown Iron" 198 + val address = result.device.address 199 + val current = _discoveredDevices.value 200 + if (current.none { it.device.address == address }) { 201 + _discoveredDevices.value = current + DiscoveredDevice(result.device, name) 187 202 } 188 203 } 189 204 ··· 193 208 } 194 209 } 195 210 196 - scanner.startScan(null, scanSettings, scanCallback) 211 + scanner.startScan(listOf(scanFilter), scanSettings, scanCallback) 197 212 198 213 scanTimeoutJob = scope.launch { 199 214 delay(10_000) ··· 201 216 scanner.stopScan(scanCallback) 202 217 } catch (_: Exception) {} 203 218 if (_connectionState.value == ConnectionState.SCANNING) { 204 - _connectionState.value = ConnectionState.DISCONNECTED 219 + val devices = _discoveredDevices.value 220 + if (devices.size == 1) { 221 + connectToDevice(devices[0].device, devices[0].name) 222 + } else { 223 + _connectionState.value = ConnectionState.DISCONNECTED 224 + } 205 225 } 206 226 } 207 227 } 208 228 209 229 @SuppressLint("MissingPermission") 210 - private suspend fun connectToDevice(device: BluetoothDevice, name: String) { 230 + suspend fun connectToDevice(device: BluetoothDevice, name: String) { 211 231 _connectionState.value = ConnectionState.CONNECTING 212 232 _deviceName.value = name 213 233
+39 -13
android/app/src/main/java/com/tinkcil/ui/components/ScanningOverlay.kt
··· 22 22 import androidx.compose.ui.unit.dp 23 23 import com.tinkcil.R 24 24 import com.tinkcil.data.ble.ConnectionState 25 + import com.tinkcil.data.ble.DiscoveredDevice 25 26 26 27 @Composable 27 28 fun ScanningOverlay( 28 29 connectionState: ConnectionState, 30 + discoveredDevices: List<DiscoveredDevice>, 31 + onDeviceSelected: (DiscoveredDevice) -> Unit, 29 32 onScanAgain: () -> Unit, 30 33 onTryDemo: () -> Unit, 31 34 modifier: Modifier = Modifier ··· 93 96 } 94 97 } 95 98 ConnectionState.DISCONNECTED -> { 96 - Text( 97 - text = stringResource(R.string.connection_no_device_found), 98 - style = MaterialTheme.typography.headlineSmall, 99 - color = MaterialTheme.colorScheme.onSurface, 100 - textAlign = TextAlign.Center 101 - ) 102 - Spacer(modifier = Modifier.height(24.dp)) 103 - Button(onClick = onScanAgain) { 104 - Text(stringResource(R.string.connection_scan_again)) 105 - } 106 - Spacer(modifier = Modifier.height(12.dp)) 107 - OutlinedButton(onClick = onTryDemo) { 108 - Text(stringResource(R.string.try_demo)) 99 + if (discoveredDevices.size > 1) { 100 + Text( 101 + text = stringResource(R.string.connection_multiple_devices), 102 + style = MaterialTheme.typography.headlineSmall, 103 + color = MaterialTheme.colorScheme.onSurface, 104 + textAlign = TextAlign.Center 105 + ) 106 + Spacer(modifier = Modifier.height(16.dp)) 107 + discoveredDevices.forEach { device -> 108 + Button( 109 + onClick = { onDeviceSelected(device) }, 110 + modifier = Modifier.fillMaxWidth(0.7f) 111 + ) { 112 + Text(device.name) 113 + } 114 + Spacer(modifier = Modifier.height(8.dp)) 115 + } 116 + Spacer(modifier = Modifier.height(8.dp)) 117 + OutlinedButton(onClick = onScanAgain) { 118 + Text(stringResource(R.string.connection_scan_again)) 119 + } 120 + } else { 121 + Text( 122 + text = stringResource(R.string.connection_no_device_found), 123 + style = MaterialTheme.typography.headlineSmall, 124 + color = MaterialTheme.colorScheme.onSurface, 125 + textAlign = TextAlign.Center 126 + ) 127 + Spacer(modifier = Modifier.height(24.dp)) 128 + Button(onClick = onScanAgain) { 129 + Text(stringResource(R.string.connection_scan_again)) 130 + } 131 + Spacer(modifier = Modifier.height(12.dp)) 132 + OutlinedButton(onClick = onTryDemo) { 133 + Text(stringResource(R.string.try_demo)) 134 + } 109 135 } 110 136 } 111 137 else -> {}
+2
android/app/src/main/java/com/tinkcil/ui/screens/home/HomeScreen.kt
··· 71 71 } else { 72 72 ScanningOverlay( 73 73 connectionState = uiState.connectionState, 74 + discoveredDevices = uiState.discoveredDevices, 75 + onDeviceSelected = viewModel::connectToDevice, 74 76 onScanAgain = viewModel::startScan, 75 77 onTryDemo = viewModel::startDemo 76 78 )
+2
android/app/src/main/java/com/tinkcil/ui/screens/home/HomeUiState.kt
··· 2 2 3 3 import com.tinkcil.data.ble.BLEError 4 4 import com.tinkcil.data.ble.ConnectionState 5 + import com.tinkcil.data.ble.DiscoveredDevice 5 6 import com.tinkcil.data.model.IronOSLiveData 6 7 import com.tinkcil.data.model.TemperaturePoint 7 8 ··· 14 15 val isDemo: Boolean = false, 15 16 val settingsCache: Map<Int, Int> = emptyMap(), 16 17 val temperatureHistory: List<TemperaturePoint> = emptyList(), 18 + val discoveredDevices: List<DiscoveredDevice> = emptyList(), 17 19 val lastError: BLEError? = null, 18 20 val isTopBarExpanded: Boolean = false, 19 21 val isSettingsSheetVisible: Boolean = false
+10
android/app/src/main/java/com/tinkcil/ui/screens/home/HomeViewModel.kt
··· 3 3 import androidx.lifecycle.ViewModel 4 4 import androidx.lifecycle.viewModelScope 5 5 import com.tinkcil.data.ble.BLEManager 6 + import com.tinkcil.data.ble.DiscoveredDevice 6 7 import com.tinkcil.data.repository.SettingsRepository 7 8 import dagger.hilt.android.lifecycle.HiltViewModel 8 9 import kotlinx.coroutines.flow.MutableStateFlow ··· 58 59 settingsCache = settings, 59 60 lastError = error, 60 61 temperatureHistory = bleManager.temperatureHistory.toList(), 62 + discoveredDevices = bleManager.discoveredDevices.value, 61 63 isTopBarExpanded = _uiState.value.isTopBarExpanded, 62 64 isSettingsSheetVisible = _uiState.value.isSettingsSheetVisible 63 65 ) 66 + }.combine(bleManager.discoveredDevices) { state, devices -> 67 + state.copy(discoveredDevices = devices) 64 68 }.collect { state -> 65 69 _uiState.value = state 66 70 if (state.settingsCache.isNotEmpty()) { ··· 81 85 82 86 fun startScan() { 83 87 bleManager.startScan() 88 + } 89 + 90 + fun connectToDevice(device: DiscoveredDevice) { 91 + viewModelScope.launch { 92 + bleManager.connectToDevice(device.device, device.name) 93 + } 84 94 } 85 95 86 96 fun startDemo() {
+1
android/app/src/main/res/values/strings.xml
··· 8 8 <string name="connection_looking_for_iron">Looking for soldering iron…</string> 9 9 <string name="connection_no_device_found">No device found</string> 10 10 <string name="connection_scan_again">Scan Again</string> 11 + <string name="connection_multiple_devices">Multiple devices found</string> 11 12 <string name="bluetooth_error_title">Bluetooth Error</string> 12 13 <string name="bluetooth_off_title">Bluetooth is Off</string> 13 14 <string name="bluetooth_off_message">Turn on Bluetooth in Settings to connect to your soldering iron.</string>
+6 -13
ios/Tinkcil/BLEManager.swift
··· 105 105 centralManager.stopScan() 106 106 isScanning = false 107 107 if connectionState == .scanning { 108 - connectionState = .disconnected 108 + if discoveredDevices.count == 1 { 109 + connect(to: discoveredDevices[0]) 110 + } else { 111 + connectionState = .disconnected 112 + } 109 113 } 110 114 } 111 115 ··· 572 576 rssi RSSI: NSNumber) { 573 577 DispatchQueue.main.async { [weak self] in 574 578 guard let self else { return } 575 - // Auto-connect to first discovered Tinkcil 576 - if self.connectedPeripheral == nil { 577 - // Match either Pinecil-* (legacy) or by the advertised service UUID 578 - if peripheral.name?.hasPrefix("Pinecil-") == true || 579 - peripheral.name?.hasPrefix("PrattlePin-") == true || 580 - (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?.contains(IronOSUUIDs.bulkDataService) == true { 581 - self.connect(to: peripheral) 582 - return 583 - } 584 - } 585 - 586 579 if !self.discoveredDevices.contains(where: { $0.identifier == peripheral.identifier }) { 587 580 self.discoveredDevices.append(peripheral) 588 581 } ··· 593 586 DispatchQueue.main.async { [weak self] in 594 587 guard let self else { return } 595 588 self.connectionState = .connected 596 - self.deviceName = peripheral.name ?? "Tinkcil" 589 + self.deviceName = peripheral.name ?? "Unknown Iron" 597 590 } 598 591 peripheral.discoverServices(nil) 599 592 }
+31
ios/Tinkcil/ContentView.swift
··· 437 437 Text(String(localized: "connection_looking_for_iron")) 438 438 .font(.subheadline) 439 439 .foregroundStyle(.secondary) 440 + } else if bleManager.discoveredDevices.count > 1 { 441 + Image(systemName: "antenna.radiowaves.left.and.right") 442 + .font(.system(size: 36)) 443 + .foregroundStyle(.secondary) 444 + .padding(.bottom, 4) 445 + .accessibilityHidden(true) 446 + 447 + Text(String(localized: "connection_multiple_devices")) 448 + .font(.headline) 449 + .accessibilityAddTraits(.isHeader) 450 + 451 + VStack(spacing: 8) { 452 + ForEach(bleManager.discoveredDevices, id: \.identifier) { peripheral in 453 + Button { 454 + Haptics.light() 455 + bleManager.connect(to: peripheral) 456 + } label: { 457 + Text(peripheral.name ?? String(localized: "common_unknown")) 458 + .frame(maxWidth: .infinity) 459 + } 460 + .buttonStyle(.borderedProminent) 461 + } 462 + } 463 + 464 + Button(String(localized: "connection_scan_again")) { 465 + Haptics.light() 466 + bleManager.startScanning() 467 + } 468 + .font(.subheadline) 469 + .foregroundStyle(.secondary) 470 + .padding(.top, 4) 440 471 } else { 441 472 Image(systemName: "antenna.radiowaves.left.and.right.slash") 442 473 .font(.system(size: 36))
+53
ios/Tinkcil/Localizable.xcstrings
··· 1089 1089 } 1090 1090 } 1091 1091 }, 1092 + "connection_multiple_devices" : { 1093 + "extractionState" : "manual", 1094 + "localizations" : { 1095 + "de" : { 1096 + "stringUnit" : { 1097 + "state" : "translated", 1098 + "value" : "Mehrere Geräte gefunden" 1099 + } 1100 + }, 1101 + "en" : { 1102 + "stringUnit" : { 1103 + "state" : "translated", 1104 + "value" : "Multiple Devices Found" 1105 + } 1106 + }, 1107 + "es" : { 1108 + "stringUnit" : { 1109 + "state" : "translated", 1110 + "value" : "Múltiples dispositivos encontrados" 1111 + } 1112 + }, 1113 + "fr" : { 1114 + "stringUnit" : { 1115 + "state" : "translated", 1116 + "value" : "Plusieurs appareils trouvés" 1117 + } 1118 + }, 1119 + "ja" : { 1120 + "stringUnit" : { 1121 + "state" : "translated", 1122 + "value" : "複数のデバイスが見つかりました" 1123 + } 1124 + }, 1125 + "ko" : { 1126 + "stringUnit" : { 1127 + "state" : "translated", 1128 + "value" : "여러 장치 발견" 1129 + } 1130 + }, 1131 + "ru" : { 1132 + "stringUnit" : { 1133 + "state" : "translated", 1134 + "value" : "Найдено несколько устройств" 1135 + } 1136 + }, 1137 + "zh-Hans" : { 1138 + "stringUnit" : { 1139 + "state" : "translated", 1140 + "value" : "发现多个设备" 1141 + } 1142 + } 1143 + } 1144 + }, 1092 1145 "connection_no_device_found" : { 1093 1146 "extractionState" : "manual", 1094 1147 "localizations" : {