diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 804f27f..25bc799 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -67,6 +67,8 @@ kotlin { implementation(libs.sqldelight.coroutines) implementation(libs.kotlinx.datetime) + implementation(libs.ble.kable) + implementation(libs.logger.napier) } iosMain.dependencies { diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 41a7e7c..448aacf 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -3,7 +3,7 @@ Back Menu - %d dBm + dBm Home @@ -31,4 +31,8 @@ Filters Sort by Clear + Scanner + CAN'T SEE YOUR DEVICE? + 1. Make sure the device is connected to a power source and powered on.\n\n2. Make sure the appropriate firmware and SoftDevice are flashed. + diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/components/RssiIcon.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/components/RssiIcon.kt index 3ca6aed..ceb9f84 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/components/RssiIcon.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/components/RssiIcon.kt @@ -42,7 +42,7 @@ fun RssiIcon(rssi: Int) { modifier = Modifier.size(24.dp), ) Text( - text = stringResource(Res.string.dbm, rssi), + text = "$rssi ${stringResource(Res.string.dbm)}", style = MaterialTheme.typography.bodySmall ) } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/AuthApi.kt index 9fccb14..acb4b34 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/AuthApi.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/AuthApi.kt @@ -5,6 +5,7 @@ import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.LoginResponseDt import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.RefreshRequestDto import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.RefreshResponseDto import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager +import io.github.aakira.napier.Napier import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.post @@ -29,7 +30,7 @@ class AuthApi( tokenManager.saveTokens(res.access, res.refresh) true } catch (e: Exception) { - println(">>>>> [AuthApi.kt] Exception: $e") + Napier.d(">>>>> [AuthApi.kt] Exception: $e") false } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/Endpoints.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/Endpoints.kt index cbcb51c..40f43cb 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/Endpoints.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/Endpoints.kt @@ -3,6 +3,10 @@ package com.digitoolsolutions.app.torquevaultkmp.data.network.api object Endpoints { const val AUTH_TOKEN = "/token/" const val AUTH_REFRESH = "/token/refresh/" - const val WORK_ORDERS = "/work-orders/able-v2/" - const val CONFIRM = "/confirm" + const val WORK_ORDERS = "work-orders" + const val ABLE_WO = "$WORK_ORDERS/able-v2/" + const val CONFIRM = "connect-wrench/" + const val MEASURE = "measure/" + const val RE_MEASURE = "remeasure/" + const val CANCEL = "cancel-by-wrench/" } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/WorkOrderApi.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/WorkOrderApi.kt index 971b2cd..9f3ea46 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/WorkOrderApi.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/api/WorkOrderApi.kt @@ -1,14 +1,24 @@ package com.digitoolsolutions.app.torquevaultkmp.data.network.api import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.ConfirmRequestDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.LoginRequestDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.LoginResponseDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.MeasureResDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.SendMeasureDto import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.WorkOrderDto import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager +import io.github.aakira.napier.Napier import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter +import io.ktor.client.request.patch import io.ktor.client.request.post import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.path class WorkOrderApi( private val client: HttpClient, @@ -17,15 +27,59 @@ class WorkOrderApi( private val baseUrl: String? get() = tokenManager.getServerUrl() - suspend fun fetchAbleWorkOrders(page: Int): List { - return client.get("$baseUrl${Endpoints.WORK_ORDERS}") { - parameter("page", page) + suspend fun fetchAbleWorkOrders(woID: String, action: String): List { + return client.get("$baseUrl/${Endpoints.ABLE_WO}") { + parameter("woID", woID) + parameter("action", action) }.body() } - suspend fun confirm(mac: String, type: Int) { - client.post("$baseUrl${Endpoints.CONFIRM}") { - setBody(ConfirmRequestDto(mac, type)) + suspend fun confirm(woID: String, deviceId: String, type: Int): Boolean { + return try { + val response = client.patch("$baseUrl/${Endpoints.WORK_ORDERS}/$woID/${Endpoints.CONFIRM}"){ + contentType(ContentType.Application.Json) + setBody(ConfirmRequestDto(deviceId, type)) + } + response.status == HttpStatusCode.OK + } catch (e: Exception) { + Napier.e(">>>>> Exception: $e", tag="WorkOrderApi.kt") + false + } + } + suspend fun sendMeasurement(woID: String, bodyPayload: SendMeasureDto, isRemeasure: Boolean = false): Result { + return try { + val endPath = if(isRemeasure) Endpoints.RE_MEASURE else Endpoints.MEASURE + val response = client.patch("$baseUrl/${Endpoints.WORK_ORDERS}/$woID/$endPath") { + contentType(ContentType.Application.Json) + setBody(bodyPayload) + } + if (response.status.value in 200..299) { + Result.success(response.body()) + } else { + Napier.e("Error status: ${response.status}") + Result.failure(Exception("HTTP ${response.status}")) + } + } catch (e: Exception) { + Napier.e("Exception: $e") + Result.failure(e) + } + } + + suspend fun cancelWorkOrder(woId: String, bodyPayload: SendMeasureDto): Result { + return try { + val response = client.patch("$baseUrl/${Endpoints.WORK_ORDERS}/$woId/${Endpoints.CANCEL}") { + contentType(ContentType.Application.Json) + setBody(bodyPayload) + } + if (response.status.value in 200..299) { + Result.success(response.body()) + } else { + Napier.e("Error status: ${response.status}") + Result.failure(Exception("HTTP ${response.status}")) + } + } catch (e: Exception) { + Napier.e("Exception: $e") + Result.failure(e) } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/dto/WorkOrderDto.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/dto/WorkOrderDto.kt index 20a4940..e855179 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/dto/WorkOrderDto.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/dto/WorkOrderDto.kt @@ -9,11 +9,11 @@ data class WorkOrderDto( @SerialName("wo_id") val id: String, val status: String, val car: String? = null, - val make: String?, + val make: String? = null, @SerialName("license_plate") val licensePlate: String, val services: Map?, - @SerialName("meas_result") val measResult: Map?, - @SerialName("meas_history") val measHistory: Map?, + @SerialName("meas_result") val measResult: MeasResultDto? = null, + @SerialName("meas_history") val measHistory: MeasResultDto? = null, @SerialName("finished_at") val finishedAt: String?, @SerialName("created_at") val createdAt: String, @SerialName("updated_at") val updatedAt: String? = null, @@ -26,9 +26,29 @@ data class ServiceDto( val wrench: String?, @SerialName("torque_required") val torqueRequired: Double ) +@Serializable +data class MeasResultDto( + val nut: Int? = null, + @SerialName("torque_val") val torqueVal: Map>? = null +) @Serializable data class ConfirmRequestDto( - @SerialName("wrench_mac_addr") val mac: String, + @SerialName("device_id") val deviceId: String, val type: Int +) +@Serializable +data class SendMeasureDto( + @SerialName("device_id") val deviceId: String, + val type: Int, + @SerialName("nuts_of_wheel") val nuts: Int? = null, + @SerialName("torque_results") val torqueData: Map>? = null, + @SerialName("oil_filter_val") val oilFilterVal: Double? = null, + @SerialName("drain_plug_val") val drainPlugVal: Double? = null +) +@Serializable +data class MeasureResDto( + val status: String, + val message: String, + val data: WorkOrderDto ) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/mapper/WorkOrderMapper.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/mapper/WorkOrderMapper.kt index c9304ba..ab6e25c 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/mapper/WorkOrderMapper.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/network/mapper/WorkOrderMapper.kt @@ -1,6 +1,9 @@ package com.digitoolsolutions.app.torquevaultkmp.data.network.mapper +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.MeasureResDto import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.WorkOrderDto +import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasResult +import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult import com.digitoolsolutions.app.torquevaultkmp.domain.model.Service import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder import kotlinx.serialization.json.doubleOrNull @@ -22,14 +25,31 @@ fun WorkOrderDto.toDomain(): WorkOrder { torqueRequired = dto.torqueRequired ) }, - measResult = measResult?.mapValues { (_, value) -> - value.jsonPrimitive.doubleOrNull ?: Double.NaN + measResult = measResult?.let { + it.torqueVal?.let { torqueVal -> + MeasResult( + nut = it.nut, + torqueVal = torqueVal + ) + } }, - measHistory = measHistory?.mapValues { (_, value) -> - value.jsonPrimitive.doubleOrNull ?: Double.NaN + measHistory = measHistory?.let { + it.torqueVal?.let { torqueVal -> + MeasResult( + nut = it.nut, + torqueVal = torqueVal + ) + } }, finishedAt = finishedAt, createdAt = createdAt, updatedAt = updatedAt ) +} +fun MeasureResDto.toDomain(): MeasureResult { + return MeasureResult( + status = status, + message = message, + data = data.toDomain() + ) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/ScannerRepository.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/ScannerRepository.kt new file mode 100644 index 0000000..6f9122a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/ScannerRepository.kt @@ -0,0 +1,26 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.repository + +import com.digitoolsolutions.app.torquevaultkmp.data.network.api.WorkOrderApi +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.MeasureResDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.SendMeasureDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.mapper.toDomain +import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult +import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder + +class ScannerRepository(private val woRepository: WorkOrderRepository) { + suspend fun getAbleWorkOrders(woID: String = "0", action: String = "next"): List { + return woRepository.fetchAbleWorkOrders(woID, action) + } + + suspend fun confirmWorkOrder(woID: String, deviceId: String, type: Int): Boolean { + return woRepository.confirm(woID, deviceId, type) + } + + suspend fun sendMeasurementResult(woId: String, data: SendMeasureDto, isReTorque: Boolean = false): Result { + return woRepository.sendMeasurement(woId, data, isReTorque) + } + + suspend fun sendCancelWorkOrder(woId: String, data: SendMeasureDto): Result { + return woRepository.cancelWorkOrder(woId, data) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepository.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepository.kt index 03bb541..af09345 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepository.kt @@ -1,8 +1,13 @@ package com.digitoolsolutions.app.torquevaultkmp.data.repository +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.MeasureResDto +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.SendMeasureDto +import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder interface WorkOrderRepository { - suspend fun fetchAbleWorkOrders(page: Int): List - suspend fun confirm(mac: String, type: Int) + suspend fun fetchAbleWorkOrders(woId: String, action: String): List + suspend fun confirm(woID: String, deviceId: String, type: Int): Boolean + suspend fun sendMeasurement(woId: String, data: SendMeasureDto, isReTorque: Boolean = false): Result + suspend fun cancelWorkOrder(woId: String, data: SendMeasureDto): Result } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepositoryImpl.kt index 85bcc2d..1dbea7c 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/WorkOrderRepositoryImpl.kt @@ -1,15 +1,31 @@ package com.digitoolsolutions.app.torquevaultkmp.data.repository import com.digitoolsolutions.app.torquevaultkmp.data.network.api.WorkOrderApi +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.SendMeasureDto import com.digitoolsolutions.app.torquevaultkmp.data.network.mapper.toDomain +import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder class WorkOrderRepositoryImpl(private val api: WorkOrderApi) : WorkOrderRepository { - override suspend fun fetchAbleWorkOrders(page: Int): List { - val dto = api.fetchAbleWorkOrders(page) + override suspend fun fetchAbleWorkOrders(woId: String, action: String): List { + val dto = api.fetchAbleWorkOrders(woId, action) return dto.map { it.toDomain() } } - override suspend fun confirm(mac: String, type: Int) { - api.confirm(mac, type) + override suspend fun confirm( + woID: String, + deviceId: String, + type: Int + ): Boolean { + return api.confirm(woID,deviceId, type) + } + + override suspend fun sendMeasurement(woId: String, data: SendMeasureDto, isReTorque: Boolean): Result { + val dto = api.sendMeasurement(woId, data, isReTorque) + return dto.map { it.toDomain() } + } + + override suspend fun cancelWorkOrder(woId: String, data: SendMeasureDto): Result { + val dto = api.cancelWorkOrder(woId, data) + return dto.map { it.toDomain() } } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt index ae9dc05..c30a4d1 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt @@ -6,15 +6,18 @@ import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi import com.digitoolsolutions.app.torquevaultkmp.data.network.api.WorkOrderApi import com.digitoolsolutions.app.torquevaultkmp.data.repository.DeviceRepository import com.digitoolsolutions.app.torquevaultkmp.data.repository.LogRepository +import com.digitoolsolutions.app.torquevaultkmp.data.repository.ScannerRepository import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepository import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepositoryImpl import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase +import com.digitoolsolutions.app.torquevaultkmp.kable.BleManager import com.digitoolsolutions.app.torquevaultkmp.domain.usecase.FetchWorkOrdersUseCase import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogViewModel +import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingViewModel import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf @@ -27,6 +30,7 @@ expect val platformModule: Module val appModule = module { // TokenManager use AppStorage single { TokenManager(get()) } + singleOf(::BleManager) } val storageModule = module { @@ -34,6 +38,7 @@ val storageModule = module { single { get().appDatabaseQueries } single { LogRepository(get()) } single { DeviceRepository(get()) } + single { ScannerRepository(get()) } } val networkModule = module { @@ -58,4 +63,5 @@ val viewModelModule = module { factoryOf(::SettingViewModel) factoryOf(::BondedViewModel) factoryOf(::LogViewModel) + factory { ScannerViewModel(get(), get(), get(), get()) } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/WorkOrder.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/WorkOrder.kt index d96620a..72fa20c 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/WorkOrder.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/WorkOrder.kt @@ -29,8 +29,8 @@ data class WorkOrder( val make: String?, val licensePlate: String, val services: Map?, - val measResult: Map?, - val measHistory: Map?, + val measResult: MeasResult?, + val measHistory: MeasResult?, val finishedAt: String?, val createdAt: String, val updatedAt: String? @@ -42,3 +42,14 @@ data class Service( val wrench: String?, val torqueRequired: Double ) + +data class MeasResult( + val nut: Int? = null, + val torqueVal: Map> +) + +data class MeasureResult( + val status: String, + val message: String, + val data: WorkOrder +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/usecase/FetchWorkOrdersUseCase.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/usecase/FetchWorkOrdersUseCase.kt index 784759b..7285164 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/usecase/FetchWorkOrdersUseCase.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/usecase/FetchWorkOrdersUseCase.kt @@ -4,7 +4,7 @@ import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderReposit import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder class FetchWorkOrdersUseCase(private val repository: WorkOrderRepository) { - suspend operator fun invoke(page: Int): List { - return repository.fetchAbleWorkOrders(page) + suspend operator fun invoke(page: Int, woId: String = "0", action: String = "next"): List { + return repository.fetchAbleWorkOrders(woId, action) } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/kable/BleManager.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/kable/BleManager.kt new file mode 100644 index 0000000..41f7c5e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/kable/BleManager.kt @@ -0,0 +1,75 @@ +package com.digitoolsolutions.app.torquevaultkmp.kable + +import com.juul.kable.Advertisement +import com.juul.kable.Peripheral +import com.juul.kable.Scanner +import com.juul.kable.characteristicOf +import com.juul.kable.logs.Logging +import com.juul.kable.logs.SystemLogEngine +import io.github.aakira.napier.Napier +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlin.uuid.Uuid +import kotlin.uuid.ExperimentalUuidApi + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +@OptIn(ExperimentalUuidApi::class) +class BleManager { + companion object { + val SERVICE_UUID = Uuid.parse("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") + val RX_CHAR = characteristicOf(SERVICE_UUID, Uuid.parse("6E400002-B5A3-F393-E0A9-E50E24DCCA9E")) + val TX_CHAR = characteristicOf(SERVICE_UUID, Uuid.parse("6E400003-B5A3-F393-E0A9-E50E24DCCA9E")) + } + + private val peripherals = mutableMapOf() + private val mutex = Mutex() + + fun scanDevices(): Flow = Scanner { + filters { + match { + services = listOf(SERVICE_UUID) + } + } + logging { + engine = SystemLogEngine + level = Logging.Level.Events + format = Logging.Format.Multiline + } + }.advertisements + + suspend fun connect(advertisement: Advertisement): Peripheral { + val identifier = advertisement.identifier.toString() + Napier.d(">>>>> Connecting to $identifier") + val peripheral = mutex.withLock { + peripherals.getOrPut(identifier) { + Peripheral(advertisement) + } + } + // Connect outside the lock to allow parallel connections for different devices + peripheral.connect() + return peripheral + } + + suspend fun disconnect(identifier: String) { + mutex.withLock { + peripherals.remove(identifier)?.disconnect() + } + Napier.d(">>>>> $identifier disconnected") + } + + suspend fun disconnectAll() { + mutex.withLock { + peripherals.values.forEach { it.disconnect() } + peripherals.clear() + } + } + + suspend fun sendCommand(peripheral: Peripheral, command: String) { + Napier.d(">>>>> Sent to uart: $command") + peripheral.write(RX_CHAR, command.encodeToByteArray()) + } + fun observeRx(peripheral: Peripheral): Flow = + peripheral.observe(TX_CHAR).map { it.decodeToString() } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt index 2349af7..2007e54 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt @@ -26,6 +26,8 @@ import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeDestination import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeScreen import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogDestination import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogScreen +import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerDestination +import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerScreen import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingDestination import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingScreen import org.jetbrains.compose.resources.painterResource @@ -43,6 +45,7 @@ fun MainScreen( ) { val destinations = listOf( HomeDestination, + ScannerDestination, BondedDestination, LogDestination, SettingDestination, @@ -111,14 +114,15 @@ fun MainScreen( ) { composable(AuthDestination.route) { LoginScreen(navController) } composable(HomeDestination.route) { - BluetoothRequirementWrapper { - HomeScreen(navController) - } + HomeScreen(navController) } composable(BondedDestination.route) { - BluetoothRequirementWrapper { - BondedScreen(navController) - } + BondedScreen(navController) + } + composable(ScannerDestination.route) { + ScannerScreen(onDeviceClick = { + // Handle device click, maybe navigate to details or connect + }) } composable(LogDestination.route) { LogScreen(navController) } composable(SettingDestination.route) { diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerDestination.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerDestination.kt new file mode 100644 index 0000000..7c2b9b2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerDestination.kt @@ -0,0 +1,12 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.scanner + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Bluetooth +import com.digitoolsolutions.app.torquevaultkmp.screens.AppIcon +import com.digitoolsolutions.app.torquevaultkmp.screens.Destinations + +object ScannerDestination: Destinations( + label = "Scanner", + icon = AppIcon.Vector(Icons.Outlined.Bluetooth), + route = "scanner" +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerScreen.kt new file mode 100644 index 0000000..aebe1b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerScreen.kt @@ -0,0 +1,188 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.scanner + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.BluetoothSearching +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.digitoolsolutions.app.torquevaultkmp.components.AppBar +import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon +import com.digitoolsolutions.app.torquevaultkmp.components.RssiIcon +import com.digitoolsolutions.app.torquevaultkmp.components.WarningView +import com.digitoolsolutions.app.torquevaultkmp.kable.BleManager +import com.juul.kable.Advertisement +import com.juul.kable.State +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import torquevaultkmp.composeapp.generated.resources.Res +import torquevaultkmp.composeapp.generated.resources.baseline_filter_list_24 +import torquevaultkmp.composeapp.generated.resources.baseline_warning_24 +import torquevaultkmp.composeapp.generated.resources.filter_title +import torquevaultkmp.composeapp.generated.resources.no_device_guide_info +import torquevaultkmp.composeapp.generated.resources.scan_empty_title +import torquevaultkmp.composeapp.generated.resources.scanner_title +import kotlin.uuid.ExperimentalUuidApi + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) +@Composable +fun ScannerScreen( + viewModel: ScannerViewModel = koinViewModel(), + onDeviceClick: (Advertisement) -> Unit +) { + val devices by viewModel.devices.collectAsState() + val connectedDevices by viewModel.connectedDevices.collectAsState() + val isScanning by viewModel.isScanning.collectAsState() + val pullToRefreshState = rememberPullToRefreshState() + + Scaffold( + topBar = { + AppBar( + title = { Text(stringResource(Res.string.scanner_title)) }, + actions = { + if (isScanning) { + CircularProgressIndicator( + modifier = Modifier.padding(4.dp).size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + IconButton(onClick = { viewModel.startScan() }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + } + } + ) + } + ) { paddingValues -> + PullToRefreshBox( + isRefreshing = false, + onRefresh = { + viewModel.refreshScan() + }, + state = pullToRefreshState, + modifier = Modifier.padding(paddingValues).fillMaxSize(), + ) { + if (devices.isEmpty()) { + WarningView( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + imageVector = Icons.AutoMirrored.Outlined.BluetoothSearching, + title = stringResource(Res.string.scan_empty_title), + hint = stringResource(Res.string.no_device_guide_info), + hintTextAlign = TextAlign.Justify, + ){} + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(devices) { device -> + val deviceId = device.identifier.toString() + val connectionState = connectedDevices[deviceId] + + DeviceListItem( + device = device, + connectionState = connectionState, + onClick = { + if (connectionState == null || connectionState is State.Disconnected) { + viewModel.connect(device) + } else { + viewModel.disconnect(deviceId) + } + viewModel.addBondedDevice(deviceId, device.name ?: "No name") + } + ) + } + } + } + } + } + + LaunchedEffect(Unit) { + viewModel.startScan() + } +} + +@OptIn(ExperimentalUuidApi::class) +@Composable +fun DeviceListItem( + device: Advertisement, + connectionState: State?, + onClick: () -> Unit +) { + val statusText = when (connectionState) { + is State.Connecting -> "Connecting..." + is State.Connected -> "Connected" + is State.Disconnecting -> "Disconnecting..." + is State.Disconnected -> "Disconnected" + null -> "" + } + + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .clickable { onClick() }, + colors = if (connectionState is State.Connected) { + CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) + } else { + CardDefaults.outlinedCardColors() + } + ) { + ListItem( + headlineContent = { + Text( + text = device.name ?: "No name", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column { + Text( + text = device.identifier.toString(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (statusText.isNotEmpty()) { + Text( + text = statusText, + style = MaterialTheme.typography.bodySmall, + color = if (connectionState is State.Connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + ) + } + } + }, + leadingContent = { + CircularIcon( + imageVector = Icons.Default.Bluetooth, + backgroundColor = if (connectionState is State.Connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer, + iconTint = if (connectionState is State.Connected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer + ) + }, + trailingContent = { + if (connectionState is State.Connecting) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } else { + RssiIcon(rssi = device.rssi) + } + } + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt new file mode 100644 index 0000000..8feee17 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt @@ -0,0 +1,284 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.scanner + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.digitoolsolutions.app.torquevaultkmp.data.network.dto.SendMeasureDto +import com.digitoolsolutions.app.torquevaultkmp.kable.BleManager +import com.digitoolsolutions.app.torquevaultkmp.data.repository.DeviceRepository +import com.digitoolsolutions.app.torquevaultkmp.data.repository.ScannerRepository +import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage +import com.digitoolsolutions.app.torquevaultkmp.data.storage.ReferKeys +import com.digitoolsolutions.app.torquevaultkmp.data.storage.load +import com.juul.kable.Advertisement +import com.juul.kable.Peripheral +import com.juul.kable.State +import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder +import com.digitoolsolutions.app.torquevaultkmp.domain.model.computeWheelsNuts +import com.digitoolsolutions.app.torquevaultkmp.utils.Helper +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource +import kotlin.uuid.ExperimentalUuidApi + +@OptIn(ExperimentalUuidApi::class) +class ScannerViewModel( + private val bleManager: BleManager, + private val storage: AppStorage, + private val deviceRepository: DeviceRepository, + private val scannerRepository: ScannerRepository +) : ViewModel() { + + private val _devices = MutableStateFlow>(emptyList()) + val devices: StateFlow> = _devices.asStateFlow() + + private val _isScanning = MutableStateFlow(false) + val isScanning: StateFlow = _isScanning.asStateFlow() + private val _connectedDevices = MutableStateFlow>(emptyMap()) + val connectedDevices: StateFlow> = _connectedDevices.asStateFlow() + + private val _workOrders = MutableStateFlow>(emptyList()) + private val _autoConnect = MutableStateFlow(storage.load(ReferKeys.DEVICE_AUTO_CONNECT) ?: false) + val autoConnect: StateFlow = _autoConnect.asStateFlow() + private val _onlyWithName = MutableStateFlow(true) + + private var scanJob: Job? = null + private var cleanupJob: Job? = null + private val connectionJobs = mutableMapOf() + + private val advertisementMap = mutableMapOf>() + + companion object { + private val SCAN_TIMEOUT = 1.seconds + } + + fun fetchWorkOrders(peripheral: Peripheral, woID: String = "0", action: String = "next") { + viewModelScope.launch { + try { + val orders = scannerRepository.getAbleWorkOrders(woID = woID, action = action) + _workOrders.value = orders + sendWorkOrdersToDevice(peripheral,orders) + } catch (e: Exception) { + Napier.e("Failed to load work orders", e) + } + } + } + + private fun sendWorkOrdersToDevice(peripheral: Peripheral, workOrders: List) { + viewModelScope.launch { + if (workOrders.isEmpty()) { + bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_EMPTY_WO)) + return@launch + } + + val formatted = workOrders.joinToString(separator = "\r\n") { wo -> + val torque = wo.services?.values?.firstOrNull()?.torqueRequired ?: 0.0 + val actionCode = if (wo.status.lowercase() == "open") 0 else 1 + val wheelsMask = wo.computeWheelsNuts() + "$${wo.id},${actionCode},${wo.make},${wo.licensePlate},$torque,ft-lb,$wheelsMask*" + } + bleManager.sendCommand(peripheral, Helper.buildUartCommand(formatted)) + } + } + + fun startScan() { + if (_isScanning.value) return + scanJob = viewModelScope.launch { + _isScanning.value = true + _devices.value = emptyList() + advertisementMap.clear() + + bleManager.scanDevices() + .catch { + _isScanning.value = false + stopCleanupJob() + } + .collect { advertisement -> + if (_onlyWithName.value && advertisement.name == null) return@collect + + val now = TimeSource.Monotonic.markNow() + advertisementMap[advertisement.identifier.toString()] = advertisement to now + startCleanupJob() + updateDeviceList() + } + } + } + + private fun startCleanupJob() { + if (cleanupJob?.isActive == true) return + cleanupJob = viewModelScope.launch { + while (advertisementMap.isNotEmpty() && isActive) { + delay(1000) + val keysToRemove = advertisementMap.filter { it.value.second.elapsedNow() > SCAN_TIMEOUT }.keys + if (keysToRemove.isNotEmpty()) { + keysToRemove.forEach { advertisementMap.remove(it) } + updateDeviceList() + } + } + } + } + + private fun stopCleanupJob() { + cleanupJob?.cancel() + cleanupJob = null + } + + private fun updateDeviceList() { + _devices.value = advertisementMap.values + .map { it.first } + .sortedByDescending { it.rssi } + } + + fun stopScan() { + scanJob?.cancel() + scanJob = null + _isScanning.value = false + stopCleanupJob() + } + + fun refreshScan() { + _devices.value = emptyList() + startScan() + } + + fun connect(adv: Advertisement) { + val deviceIdentifier = adv.identifier.toString() + if (connectionJobs[deviceIdentifier]?.isActive == true) return + + val job = viewModelScope.launch { + try { + val peripheral = bleManager.connect(adv) + + coroutineScope { + // Tracking connection state + launch { + peripheral.state.collect { state -> + _connectedDevices.value += (deviceIdentifier to state) + if (state is State.Disconnected) { + this@coroutineScope.cancel(">>>>> Device disconnected") + } + } + } + + // Listen rx data + launch { + bleManager.observeRx(peripheral).collectLatest { value -> + handleMessage(peripheral, value, deviceIdentifier) + } + } + } + } catch (e: Exception) { + if (e !is CancellationException) { + Napier.e(">>>>> Connection failed for $deviceIdentifier", e) + } + } finally { + _connectedDevices.value -= deviceIdentifier + connectionJobs.remove(deviceIdentifier) + bleManager.disconnect(deviceIdentifier) + Napier.d(">>>>> Cleaned up connection for $deviceIdentifier") + } + } + connectionJobs[deviceIdentifier] = job + } + + fun disconnect(deviceIdentifier: String) { + viewModelScope.launch { + bleManager.disconnect(deviceIdentifier) + connectionJobs[deviceIdentifier]?.cancel() + connectionJobs.remove(deviceIdentifier) + _connectedDevices.value -= deviceIdentifier + } + } + + private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) { + val decoded = Helper.decodeRxData(msg) ?: return + val data = decoded.data + val woId = decoded.id + val cmd = data.take(4) + val action = if (cmd == "back") "back" else "next" + Napier.d(">>>>> Received data from $deviceIdentifier: $msg", tag="ScannerViewModel.kt") + Napier.d(">>>>> Data: $data", tag="ScannerViewModel.kt") + viewModelScope.launch { + when (cmd) { + "requ", "next", "back" -> fetchWorkOrders(peripheral, woId, action) + "conf" -> { + try { + val res = scannerRepository.confirmWorkOrder(woId, deviceIdentifier, 0) + var command = Helper.CMD_NOT_AVAILABLE_WO + if (res) + command = Helper.CMD_RECEIVED_WO + bleManager.sendCommand(peripheral, Helper.buildUartCommand(command)) + } catch (e: Exception) { + Napier.e(">>>>> Failed to handle message: $msg", e) + bleManager.sendCommand( + peripheral, Helper.buildUartCommand(Helper.CMD_NOT_AVAILABLE_WO) + ) + } + } + else -> { + /** Send torque, re-torque or cancel case */ + val bleData = data.trim(' ').trimEnd('*').split(",") + val nuts: Int = bleData[Helper.BLE_NUTS_POS].toInt() + val torqueData: List = bleData.subList(Helper.BLE_TORQUE_POS, bleData.size) + val wheelTorqueData = Helper.parseWheelTorqueData(torqueData, nuts) + val dto = SendMeasureDto( + deviceId = deviceIdentifier, + type = 0, // tbu + nuts = nuts, + torqueData = wheelTorqueData + ) + when (val act = bleData.firstOrNull()) { + Helper.TorqueAction.TORQUE -> { + try { + scannerRepository.sendMeasurementResult(woId, dto) + } catch (e: Exception) { + Napier.e("An error occurred while sending the measurement", e, tag = "ScannerViewModel") + } finally { + // Currently, always send UPLOADED_WO regardless of success or failure + bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_UPLOADED_WO)) + } + } + Helper.TorqueAction.RE_TORQUE -> { + try { + scannerRepository.sendMeasurementResult(woId, dto, true) + } catch (e: Exception) { + Napier.e("An error occurred while sending the re-measurement", e, tag = "ScannerViewModel") + } finally { + // Currently, always send UPLOADED_WO regardless of success or failure + bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_UPLOADED_WO)) + } + } + Helper.TorqueAction.CANCEL -> { + try { + scannerRepository.sendCancelWorkOrder(woId, dto) + } catch (e: Exception) { + Napier.e("An error occurred while cancel work order", e, tag = "ScannerViewModel") + } finally { + // Currently, always send UPLOADED_WO regardless of success or failure + bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_UPLOADED_WO)) + } + } + else -> Napier.w(">>>>> Unknown action: $act") + } + } + } + } + } + + fun addBondedDevice(id: String, name: String) { + viewModelScope.launch { + deviceRepository.saveDevice(id, name) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/Helper.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/Helper.kt new file mode 100644 index 0000000..77900c5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/Helper.kt @@ -0,0 +1,55 @@ +package com.digitoolsolutions.app.torquevaultkmp.utils + +object Helper { + const val CMD_EMPTY_WO = $$"$e*\r\n" + const val CMD_RECEIVED_WO = $$"$r*\r\n" + const val CMD_NOT_AVAILABLE_WO = $$"$n*\r\n" + const val CMD_UPLOADED_WO = $$"$u*\r\n" + const val END_LINE_FEED = "#\r\n" + + const val BLE_NUTS_POS = 4 + const val BLE_TORQUE_POS = BLE_NUTS_POS + 1 + + object TorqueAction { + const val TORQUE = "0" + const val RE_TORQUE = "1" + const val CANCEL = "2" + } + data class RxMessage( + val id: String, + val data: String + ) + + fun buildUartCommand(cmd: String): String { + return "$cmd$END_LINE_FEED" + } + + fun decodeRxData(msg: String): RxMessage? { + if (!msg.startsWith("$")) return null + val payload = msg.drop(1).trimEnd('*', ' ', '\r', '\n') + val parts = payload.split(",", limit = 2) + if (parts.size < 2) return null + val woID = parts[0] + val data = parts[1] + return RxMessage(woID, data) + } + + fun parseWheelTorqueData(torqueData: List, nutsPerWheel: Int): Map> { + val result = mutableMapOf>() + var i = 0 + while (i < torqueData.size) { + val wheel = torqueData[i] // Wheel name + val torques = ArrayList(nutsPerWheel) + for (j in 0 until nutsPerWheel) { + val index = i + 1 + j + if (index < torqueData.size) { + val value = torqueData[index].toDoubleOrNull() ?: 0.0 + torques.add(value) + } + } + result[wheel] = torques + i += nutsPerWheel + 1 + } + return result + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2f8b32a..c63de3c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ navigation-compose = "2.9.2" logger = "2.7.1" sqldelight = "2.3.2" kotlinx-datetime = "0.8.0" +kable = "0.43.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -66,6 +67,7 @@ sqldelight-android = { module = "app.cash.sqldelight:android-driver", version.re sqldelight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +ble-kable = { module = "com.juul.kable:kable-core", version.ref = "kable" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }