Update Readme.md, add docstring

This commit is contained in:
2026-07-23 13:20:13 +07:00
parent 8af98f505f
commit 557681cbeb
51 changed files with 731 additions and 68 deletions
+64 -27
View File
@@ -1,35 +1,72 @@
This is a Kotlin Multiplatform project targeting Android, iOS.
# TorqueVaultKMP
* [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications.
It contains several subfolders:
- [commonMain](./composeApp/src/commonMain/kotlin) is for code thats common for all targets.
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
For example, if you want to use Apples CoreCrypto for the iOS part of your Kotlin app,
the [iosMain](./composeApp/src/iosMain/kotlin) folder would be the right place for such calls.
Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./composeApp/src/jvmMain/kotlin)
folder is the appropriate location.
TorqueVaultKMP is a Kotlin Multiplatform (KMP) application designed for managing torque measurements, connecting to digital torque devices via Bluetooth Low Energy (BLE), and handling work orders.
* [/iosApp](./iosApp/iosApp) contains iOS applications. Even if youre sharing your UI with Compose Multiplatform,
you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
The project targets **Android** and **iOS** using **Compose Multiplatform** for a shared UI and logic.
### Build and Run Android Application
## 🚀 Key Features
To build and run the development version of the Android app, use the run configuration from the run widget
in your IDEs toolbar or build it directly from the terminal:
- on macOS/Linux
```shell
./gradlew :composeApp:assembleDebug
```
- on Windows
```shell
.\gradlew.bat :composeApp:assembleDebug
```
- **BLE Device Management**: Scan, connect, and bond with digital torque wrenches using the [Kable](https://github.com/JuulLabs/kable) library.
- **Adaptive Scanning**: Automatically calibrates device discovery expiration by observing advertisement intervals, ensuring a responsive UI when devices stop broadcasting.
- **Work Order Workflow**:
- Fetch "Open" work orders from a remote API.
- Synchronize work order details (License Plate, Make, Torque requirements) with connected BLE devices.
- Receive and upload measurement results (Torque values, wheel/nut indices) back to the server.
- **Bonding & Persistence**: Automatically remembers and auto-connects to bonded devices using **SQLDelight** for local storage.
- **Diagnostic Logging**: A dedicated in-app logging system (`LogRepository`) that tracks system events, BLE communication, and HTTP requests. Supports exporting logs as CSV or Text for remote debugging.
- **Theme Support**: Built-in support for Dark and Light modes.
### Build and Run iOS Application
## 🛠 Tech Stack
To build and run the development version of the iOS app, use the run configuration from the run widget
in your IDEs toolbar or open the [/iosApp](./iosApp) directory in Xcode and run it from there.
- **Kotlin**: `2.3.21`
- **UI Framework**: [Compose Multiplatform](https://www.jetbrains.com/lp/compose-multiplatform/) `1.11.0`
- **Dependency Injection**: [Koin](https://insert-koin.io/) `4.2.1`
- **Networking**: [Ktor](https://ktor.io/) `3.4.3`
- **Database**: [SQLDelight](https://cashapp.github.io/sqldelight/) `2.3.2`
- **Bluetooth**: [Kable](https://github.com/JuulLabs/kable) `0.43.0`
- **Logging**: [Napier](https://github.com/aakira/Napier) `2.7.1`
- **Time/Date**: [Kotlinx Datetime](https://github.com/Kotlin/kotlinx-datetime) `0.8.0`
- **Serialization**: [Kotlinx Serialization](https://github.com/Kotlin/kotlinx.serialization) `2.3.21`
## 📁 Project Structure
- `composeApp/src/commonMain`: Shared logic, UI, and data management.
- `screens/`: UI modules for Scanner, Logs, Home, Settings, and Auth.
- `data/`: Network DTOs, SQLDelight DB schema, and Repositories.
- `kable/`: BLE abstraction and manager.
- `domain/`: Business logic models and shared interfaces.
- `composeApp/src/androidMain`: Android-specific implementations (e.g., Bluetooth permissions, Splash screen).
- `composeApp/src/iosMain`: iOS-specific implementations (e.g., Native sharing sheets, CoreBluetooth delegates).
- `iosApp`: Entry point for the iOS application.
## 🏗 Getting Started
### Prerequisites
- Android Studio (Ladybug or newer) / IntelliJ IDEA.
- Xcode 15+ (for iOS development).
- Kotlin Multiplatform plugin.
### Build and Run
#### Android
```bash
./gradlew :composeApp:assembleDebug
```
#### iOS
Open the `iosApp` directory in Xcode or use the run configuration in Android Studio.
## ⚙️ Communication Protocol
The app uses a custom UART protocol to communicate with torque devices. Commands include:
- `START_WO`: Initiate work order fetch.
- `ACCEPT_WO`: Confirm a specific work order selection.
- `FINISH_WO`: Send measurement data back to the app for server upload.
## 📜 Logging
Logs are stored in memory (up to 1000 entries) and categorized into **System** and **Device** logs. They can be shared via the **Logs** screen using platform-native sharing components.
---
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)…
Developed by **Digitool Solutions**.
@@ -16,6 +16,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Android implementation of [BluetoothManager].
*
* Manages Bluetooth adapter state and handles runtime permissions (BLUETOOTH_SCAN,
* BLUETOOTH_CONNECT for API 31+, and ACCESS_FINE_LOCATION for older versions).
*/
class AndroidBluetoothManager(private val context: Context) : BluetoothManager {
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as AndroidBluetoothManager
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
@@ -9,6 +9,12 @@ import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerViewModel
import com.digitoolsolutions.app.torquevaultkmp.theme.TorqueVaultTheme
import org.koin.compose.viewmodel.koinViewModel
/**
* Entry point for the Compose Multiplatform application.
*
* It manages the root theme, ensures system requirements are met via [RequirementWrapper],
* and hosts the [MainScreen] which contains the application's navigation graph.
*/
@Composable
@Preview
fun App(
@@ -13,41 +13,72 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* Core ViewModel for managing global application state across all screens.
*
* Responsibilities include:
* - Persisting and retrieving theme preferences.
* - Monitoring authentication session expiration.
* - Controlling visibility of diagnostic logs.
*/
class AppViewModel(
private val tokenManager: TokenManager,
private val storage: AppStorage
) : ViewModel() {
/**
* Observable state for the current theme.
* If null, the system theme is used.
*/
private val _isDarkTheme = mutableStateOf<Boolean?>(storage.load(ReferKeys.THEME))
val isDarkTheme: State<Boolean?> = _isDarkTheme
/**
* Notifies the UI when the authentication token has expired.
*/
private val _sessionExpired = mutableStateOf(false)
val sessionExpired: State<Boolean> = _sessionExpired
/**
* Determines if the Debug Logs tab should be visible in the navigation bar.
*/
private val _showLogs = MutableStateFlow(false)
val showLogs: StateFlow<Boolean> = _showLogs
init {
viewModelScope.launch {
// Observe the token manager for session expiration events
tokenManager.sessionExpired.collectLatest {
_sessionExpired.value = true
}
}
}
/**
* Resets the session expiration alert.
*/
fun dismissSessionExpired() {
_sessionExpired.value = false
}
/**
* Checks if a valid session exists.
*/
fun isLoggedIn(): Boolean {
return tokenManager.getRefreshToken() != null
}
/**
* Toggles between Light and Dark mode and persists the choice.
*/
fun toggleTheme(isDark: Boolean) {
_isDarkTheme.value = isDark
storage.save(ReferKeys.THEME, isDark)
}
/**
* Enables or disables the visibility of diagnostic logs.
*/
fun setShowLogs(enabled: Boolean) {
_showLogs.value = enabled
}
@@ -4,4 +4,12 @@ import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi
import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager
import io.ktor.client.HttpClient
/**
* Factory function to create a platform-specific [HttpClient].
*
* Each platform (Android, iOS) provides its own implementation to handle
* specific requirements like OkHttp or Darwin engines, and to integrate
* with the [TokenManager] for automated auth header injection and
* token refreshing.
*/
expect fun createPlatformHttpClient(tokenManager: TokenManager, authApi: AuthApi?): HttpClient
@@ -1,7 +1,14 @@
package com.digitoolsolutions.app.torquevaultkmp
/**
* Interface representing the current operating platform (Android or iOS).
*/
interface Platform {
/** The name of the platform (e.g., "Android 34", "iOS 17.2"). */
val name: String
}
expect fun getPlatform(): Platform
/**
* Returns the [Platform] implementation for the current target.
*/
expect fun getPlatform(): Platform
@@ -25,6 +25,16 @@ import torquevaultkmp.composeapp.generated.resources.baseline_menu_24
import torquevaultkmp.composeapp.generated.resources.menu_item_accessibility
import torquevaultkmp.composeapp.generated.resources.navigation_item_accessibility
/**
* A customized [TopAppBar] consistent with the application's theme.
*
* Supports a back button, a hamburger menu button, and custom actions.
*
* @param title The composable to be displayed as the title.
* @param onNavigationButtonClick Callback for the back navigation button.
* @param onHamburgerButtonClick Callback for the side menu button.
* @param actions The actions to be displayed on the right side of the app bar.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppBar(
@@ -12,6 +12,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* A large icon component used for placeholder screens or large visual indicators.
*/
@Composable
internal fun BigIcon(
imageVector: ImageVector,
@@ -27,6 +30,9 @@ internal fun BigIcon(
)
}
/**
* A version of [BigIcon] that uses a [Painter] instead of an [ImageVector].
*/
@Composable
internal fun BigIcon(
painterResource: Painter,
@@ -40,4 +46,4 @@ internal fun BigIcon(
modifier = modifier.size(size),
colorFilter = ColorFilter.tint(color),
)
}
}
@@ -14,7 +14,11 @@ import org.jetbrains.compose.resources.painterResource
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.baseline_close_24
/**
* A customized [FilterChip] component used for toggling filters in lists (e.g., in the Logs screen).
*
* When selected, it displays a close icon; otherwise, it displays the provided [icon].
*/
@Composable
fun FilterButton(
title: String,
@@ -34,6 +38,9 @@ fun FilterButton(
)
}
/**
* A version of [FilterButton] that takes a [Painter].
*/
@Composable
fun FilterButton(
title: String,
@@ -60,6 +67,9 @@ fun FilterButton(
)
}
/**
* A version of [FilterButton] that takes an [ImageVector].
*/
@Composable
fun FilterButton(
title: String,
@@ -77,4 +87,4 @@ fun FilterButton(
containerColorDisabled = containerColorDisabled,
onClick = onClick
)
}
}
@@ -10,7 +10,10 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
/**
* A reusable text component typically used for displaying hints or
* instructional labels with centered alignment by default.
*/
@Composable
internal fun Hint(
text: String,
@@ -28,6 +31,9 @@ internal fun Hint(
)
}
/**
* An [AnnotatedString] version of the [Hint] component.
*/
@Composable
internal fun Hint(
text: AnnotatedString,
@@ -43,4 +49,4 @@ internal fun Hint(
modifier = modifier,
textAlign = textAlign
)
}
}
@@ -47,6 +47,9 @@ fun RssiIcon(rssi: Int) {
)
}
}
/**
* Determines the appropriate signal strength icon resource based on the RSSI value.
*/
private fun getImageRes(rssi: Int): DrawableResource = when {
rssi < MEDIUM_RSSI -> Res.drawable.ic_signal_min
rssi < MAX_RSSI -> Res.drawable.ic_signal_medium
@@ -14,6 +14,11 @@ import io.ktor.http.ContentType
import io.ktor.http.contentType
/**
* API service for authentication-related operations.
*
* Handles user login and authentication token refreshing.
*/
class AuthApi(
private val client: HttpClient,
private val tokenManager: TokenManager
@@ -21,6 +26,9 @@ class AuthApi(
private val baseUrl: String?
get() = tokenManager.getServerUrl()
/**
* Attempts to authenticate the user and stores the received tokens.
*/
suspend fun login(username: String, password: String): Boolean {
return try {
val res: LoginResponseDto = client.post("$baseUrl${Endpoints.AUTH_TOKEN}") {
@@ -34,6 +42,11 @@ class AuthApi(
false
}
}
/**
* Attempts to refresh the access token using the stored refresh token.
*
* @return The new access token if successful, or null if the refresh token is invalid/expired.
*/
suspend fun refreshToken(): String? {
val refreshToken = tokenManager.getRefreshToken() ?: return null
return try {
@@ -1,5 +1,8 @@
package com.digitoolsolutions.app.torquevaultkmp.data.network.api
/**
* Constant values for API endpoints.
*/
object Endpoints {
const val AUTH_TOKEN = "/token/"
const val AUTH_REFRESH = "/token/refresh/"
@@ -9,4 +12,4 @@ object Endpoints {
const val MEASURE = "measure/"
const val RE_MEASURE = "remeasure/"
const val CANCEL = "cancel-by-wrench/"
}
}
@@ -20,6 +20,9 @@ import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.http.path
/**
* API service for managing work orders and torque measurements.
*/
class WorkOrderApi(
private val client: HttpClient,
private val tokenManager: TokenManager
@@ -27,6 +30,9 @@ class WorkOrderApi(
private val baseUrl: String?
get() = tokenManager.getServerUrl()
/**
* Retrieves a list of work orders available for the device.
*/
suspend fun fetchAbleWorkOrders(woID: String, action: String): List<WorkOrderDto> {
return client.get("$baseUrl/${Endpoints.ABLE_WO}") {
parameter("wo_id", woID)
@@ -34,6 +40,9 @@ class WorkOrderApi(
}.body()
}
/**
* Confirms that a specific device is handling a work order.
*/
suspend fun confirm(woID: String, deviceId: String, type: Int): Boolean {
return try {
val response = client.patch("$baseUrl/${Endpoints.WORK_ORDERS}/$woID/${Endpoints.CONFIRM}"){
@@ -46,6 +55,9 @@ class WorkOrderApi(
false
}
}
/**
* Uploads measurement results for a specific work order.
*/
suspend fun sendMeasurement(woID: String, bodyPayload: SendMeasureDto, isRemeasure: Boolean = false): Result<MeasureResDto> {
return try {
val endPath = if(isRemeasure) Endpoints.RE_MEASURE else Endpoints.MEASURE
@@ -2,7 +2,6 @@ package com.digitoolsolutions.app.torquevaultkmp.data.network.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class WorkOrderDto(
@@ -9,6 +9,10 @@ import com.digitoolsolutions.app.torquevaultkmp.domain.model.LoginResponse
import com.digitoolsolutions.app.torquevaultkmp.domain.model.RefreshRequest
import com.digitoolsolutions.app.torquevaultkmp.domain.model.RefreshResponse
/**
* Extension functions to map Authentication Network DTOs to Domain models.
*/
fun LoginRequestDto.toDomain(): LoginRequest {
return LoginRequest(username,password)
}
@@ -21,4 +25,4 @@ fun RefreshRequestDto.toDomain(): RefreshRequest {
}
fun RefreshResponseDto.toDomain(): RefreshResponse {
return RefreshResponse(access)
}
}
@@ -6,8 +6,10 @@ 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
import kotlinx.serialization.json.jsonPrimitive
/**
* Extension functions to map Work Order Network DTOs to Domain models.
*/
fun WorkOrderDto.toDomain(): WorkOrder {
return WorkOrder(
@@ -46,10 +48,11 @@ fun WorkOrderDto.toDomain(): WorkOrder {
updatedAt = updatedAt
)
}
fun MeasureResDto.toDomain(): MeasureResult {
return MeasureResult(
status = status,
message = message,
data = data.toDomain()
)
}
}
@@ -8,24 +8,48 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
/**
* Repository for managing bonded BLE devices in the local database.
*
* It uses SQLDelight to persist device identifiers and names, facilitating
* automatic reconnection features.
*/
class DeviceRepository(
private val dbQueries: AppDatabaseQueries
) {
companion object {
const val PAGE_SIZE = 20L
}
/**
* Persists a device to the local database.
* If the device already exists, it will be updated.
*/
suspend fun saveDevice(id: String, name: String) {
dbQueries.insertDevice(id, name)
}
/**
* Returns a flow of all saved devices.
*/
fun getAllDevices(): Flow<List<Device>> =
dbQueries.getAllDevices()
.asFlow()
.mapToList(Dispatchers.IO)
/**
* Returns a flow containing only the identifiers of all saved devices.
*/
suspend fun getAllDeviceIdsFlow(): Flow<List<String>> {
return dbQueries.getAllDeviceIds().asFlow().mapToList(Dispatchers.IO)
}
/**
* Retrieves a paged list of devices for efficient UI rendering.
*
* @param limit Maximum number of items to return.
* @param lastId The identifier of the last item in the previous page for cursor-based pagination.
*/
suspend fun getDevicesPaged(limit: Long = PAGE_SIZE, lastId: String? = null): List<Device> {
return if (lastId == null) {
dbQueries.getDevicesFirstPage(limit).executeAsList()
@@ -34,8 +58,14 @@ class DeviceRepository(
}
}
/**
* Retrieves a specific device by its identifier.
*/
suspend fun getDeviceById(id: String): Device? =
dbQueries.getDeviceById(id).executeAsOneOrNull()
/**
* Removes a device from the bonded list.
*/
suspend fun deleteDevice(id: String) = dbQueries.deleteDeviceById(id)
}
@@ -11,6 +11,16 @@ import kotlin.time.Clock
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Represents a single log entry captured within the application.
*
* @property id Unique identifier for the log entry.
* @property title A short descriptive title of the event.
* @property content Detailed information about the event.
* @property timestamp Epoch time in milliseconds when the log was created.
* @property deviceId The identifier of the BLE device if the log is device-specific.
* @property source Categorization of the log (e.g., SYSTEM or DEVICE).
*/
data class Log(
val id: String,
val title: String,
@@ -19,12 +29,25 @@ data class Log(
val deviceId: String?,
val source: String,
)
/**
* Repository responsible for managing diagnostic logs in memory.
* It maintains a buffer of the last [MAX_LOGS] entries and provides
* filtering capabilities for the UI.
*/
class LogRepository() {
companion object {
private const val MAX_LOGS = 1000
}
private val _logs = MutableStateFlow<List<Log>>(emptyList())
val logs: StateFlow<List<Log>> = _logs
/**
* Appends a new log entry to the buffer.
*
* @param title The title of the log.
* @param content The message or data to log.
* @param deviceId Optional device identifier. If null, the log is marked as [LogFilter.SYSTEM].
*/
@OptIn(ExperimentalUuidApi::class)
fun appendLog(title: String, content: String, deviceId: String? = null) {
val source = if (deviceId == null) LogFilter.SYSTEM else LogFilter.DEVICE
@@ -1,25 +1,40 @@
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
/**
* High-level repository used by [ScannerViewModel] to manage work order state
* for connected BLE devices.
*
* It acts as a bridge between the BLE scanning logic and the [WorkOrderRepository].
*/
class ScannerRepository(private val woRepository: WorkOrderRepository) {
/**
* Fetches a list of work orders available for a device.
*/
suspend fun getAbleWorkOrders(woID: String = "0", action: String = "next"): List<WorkOrder> {
return woRepository.fetchAbleWorkOrders(woID, action)
}
/**
* Confirms the selection of a specific work order on a device.
*/
suspend fun confirmWorkOrder(woID: String, deviceId: String, type: Int): Boolean {
return woRepository.confirm(woID, deviceId, type)
}
/**
* Uploads measurement results received from the BLE device to the server.
*/
suspend fun sendMeasurementResult(woId: String, data: SendMeasureDto, isReTorque: Boolean = false): Result<MeasureResult> {
return woRepository.sendMeasurement(woId, data, isReTorque)
}
/**
* Notifies the server that a work order has been cancelled by the operator on the device.
*/
suspend fun sendCancelWorkOrder(woId: String, data: SendMeasureDto): Result<WorkOrder> {
return woRepository.cancelWorkOrder(woId, data)
}
@@ -1,10 +1,12 @@
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 defining the operations for managing work orders via remote API.
*/
interface WorkOrderRepository {
suspend fun fetchAbleWorkOrders(woId: String, action: String): List<WorkOrder>
suspend fun confirm(woID: String, deviceId: String, type: Int): Boolean
@@ -5,6 +5,9 @@ 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
/**
* Implementation of [WorkOrderRepository] that communicates with the backend API.
*/
class WorkOrderRepositoryImpl(private val api: WorkOrderApi) : WorkOrderRepository {
override suspend fun fetchAbleWorkOrders(woId: String, action: String): List<WorkOrder> {
val dto = api.fetchAbleWorkOrders(woId, action)
@@ -16,7 +19,7 @@ class WorkOrderRepositoryImpl(private val api: WorkOrderApi) : WorkOrderReposito
deviceId: String,
type: Int
): Boolean {
return api.confirm(woID,deviceId, type)
return api.confirm(woID, deviceId, type)
}
override suspend fun sendMeasurement(woId: String, data: SendMeasureDto, isReTorque: Boolean): Result<MeasureResult> {
@@ -3,10 +3,32 @@ package com.digitoolsolutions.app.torquevaultkmp.data.storage
import kotlinx.coroutines.flow.Flow
import kotlin.reflect.KClass
/**
* Platform-agnostic interface for persistent key-value storage.
*
* Implementations should handle storing simple data types like Booleans,
* Strings, and Numbers using native mechanisms (e.g., SharedPreferences on Android,
* NSUserDefaults on iOS).
*/
interface AppStorage {
/**
* Persists a value for the given key.
*/
fun <T : Any> save(key: String, value: T)
/**
* Retrieves a value for the given key.
*/
fun <T : Any> load(key: String, type: KClass<T>): T?
/**
* Removes the data associated with the given key.
*/
fun remove(key: String)
/**
* Returns a flow that emits updates whenever the value for the given key changes.
*/
fun <T : Any> observe(key: String, type: KClass<T>): Flow<T>
}
@@ -1,9 +1,21 @@
package com.digitoolsolutions.app.torquevaultkmp.data.storage
/**
* Constants used as keys for persistent storage throughout the application.
*/
object ReferKeys {
/** The access token used for authenticated API requests. */
const val ACCESS_TOKEN = "access_token"
/** The refresh token used to obtain new access tokens. */
const val REFRESH_TOKEN = "refresh_token"
/** The base URL of the remote server. */
const val SERVER_URL = "server_url"
/** User preference for Dark Mode (Boolean). */
const val THEME = "theme"
/** User preference for automatic reconnection to bonded devices (Boolean). */
const val DEVICE_AUTO_CONNECT = "device_auto_connect"
}
@@ -3,10 +3,22 @@ package com.digitoolsolutions.app.torquevaultkmp.data.storage
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* Manages authentication tokens and server URL configuration.
*
* This class handles token caching, persistence via [AppStorage], and
* notifies the application when the user session has expired.
*/
class TokenManager(private val storage: AppStorage) {
/**
* A flow that emits when the authentication session has expired.
*/
private val _sessionExpired = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val sessionExpired = _sessionExpired.asSharedFlow()
/**
* Triggers the session expiration event, typically called by a Ktor interceptor.
*/
fun triggerSessionExpired() {
_sessionExpired.tryEmit(Unit)
}
@@ -3,6 +3,12 @@ package com.digitoolsolutions.app.torquevaultkmp.di
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
/**
* Initializes the Koin dependency injection framework.
*
* This function is called from both Android and iOS entry points to
* set up the shared and platform-specific modules.
*/
fun initKoin(config: KoinAppDeclaration? = null) {
startKoin {
config?.invoke(this)
@@ -25,14 +25,23 @@ import org.koin.core.module.dsl.singleOf
import org.koin.core.qualifier.named
import org.koin.dsl.module
/**
* Platform-specific module to be provided by each target (Android, iOS).
*/
expect val platformModule: Module
/**
* Shared application module providing core utility classes.
*/
val appModule = module {
// TokenManager use AppStorage
single { TokenManager(get()) }
singleOf(::BleManager)
}
/**
* Shared storage module providing database, repositories, and local persistence logic.
*/
val storageModule = module {
single { AppDatabase(get()) }
single { get<AppDatabase>().appDatabaseQueries }
@@ -41,6 +50,9 @@ val storageModule = module {
single { ScannerRepository(get()) }
}
/**
* Shared network module providing Ktor clients, API services, and network-bound repositories.
*/
val networkModule = module {
// Auth client (no interceptor to avoid circular dependency and recursion)
single(named("authClient")) { createPlatformHttpClient(get(), null) }
@@ -56,6 +68,9 @@ val networkModule = module {
factory { FetchWorkOrdersUseCase(get()) }
}
/**
* Shared ViewModel module providing state management for the UI screens.
*/
val viewModelModule = module {
factoryOf(::HomeViewModel)
factory { AuthViewModel(get(), get()) }
@@ -1,13 +1,21 @@
package com.digitoolsolutions.app.torquevaultkmp.domain.model
/**
* Mapping of wheel positions to bitmask values for the UART protocol.
*/
val wheelsCode = mapOf(
"DF" to 0b0001,
"PF" to 0b0010,
"DR" to 0b0100,
"PR" to 0b1000,
"DRO" to 0b00010000,
"PRO" to 0b100000
"DF" to 0b0001, // Driver Front
"PF" to 0b0010, // Passenger Front
"DR" to 0b0100, // Driver Rear
"PR" to 0b1000, // Passenger Rear
"DRO" to 0b00010000, // Driver Rear Outer
"PRO" to 0b100000 // Passenger Rear Outer
)
/**
* Computes a bitmask representing the wheels and the number of nuts for the protocol.
* The resulting 16-bit integer contains the wheel mask in the upper 8 bits
* and the nut count in the lower 8 bits.
*/
fun WorkOrder.computeWheelsNuts(): Int {
var wNut = 0
var nutCount = 0
@@ -22,6 +30,9 @@ fun WorkOrder.computeWheelsNuts(): Int {
return (wNut shl 8) or nutCount
}
/**
* Domain model representing a Work Order.
*/
data class WorkOrder(
val id: String,
val status: String,
@@ -35,6 +46,10 @@ data class WorkOrder(
val createdAt: String,
val updatedAt: String?
)
/**
* Details of a service requested within a [WorkOrder], specifically for torque tasks.
*/
data class Service(
val nut: Int?,
val status: Int,
@@ -3,7 +3,17 @@ package com.digitoolsolutions.app.torquevaultkmp.domain.usecase
import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepository
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
/**
* Use case responsible for retrieving work orders from the repository.
*/
class FetchWorkOrdersUseCase(private val repository: WorkOrderRepository) {
/**
* Executes the use case to fetch work orders.
*
* @param page Current page index (for pagination).
* @param woId Pivot work order identifier for cursor-based navigation.
* @param action Navigation direction: "next" or "back".
*/
suspend operator fun invoke(page: Int, woId: String = "0", action: String = "next"): List<WorkOrder> {
return repository.fetchAbleWorkOrders(woId, action)
}
@@ -15,11 +15,22 @@ import kotlin.uuid.ExperimentalUuidApi
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* A wrapper around the Kable library to handle Bluetooth Low Energy operations.
*
* This manager provides high-level abstractions for scanning, connecting, and
* communicating with digital torque devices using a custom UART protocol.
*/
@OptIn(ExperimentalUuidApi::class)
class BleManager {
companion object {
/** The primary Service UUID for the torque device UART protocol. */
val SERVICE_UUID = Uuid.parse("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
/** The characteristic used to send commands to the device. */
val RX_CHAR = characteristicOf(SERVICE_UUID, Uuid.parse("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"))
/** The characteristic used to receive data from the device. */
val TX_CHAR = characteristicOf(SERVICE_UUID, Uuid.parse("6E400003-B5A3-F393-E0A9-E50E24DCCA9E"))
}
@@ -31,6 +42,11 @@ class BleManager {
fun getAdvertisement(identifier: String): Advertisement? = advertisements[identifier]
fun getAdvertisementName(identifier: String): String = advertisements[identifier]?.name.toString()
/**
* Scans for nearby torque devices that support the [SERVICE_UUID].
*
* @return A flow of [Advertisement] discovered during the scan.
*/
fun scanDevices(): Flow<Advertisement> = Scanner {
filters {
match {
@@ -47,6 +63,12 @@ class BleManager {
it
}
/**
* Establishes a connection to a specific peripheral.
*
* @param advertisement The advertisement discovered during scanning.
* @return A [Peripheral] instance for further interaction.
*/
suspend fun connect(advertisement: Advertisement): Peripheral {
val identifier = advertisement.identifier.toString()
Napier.d(">>>>> Connecting to $identifier")
@@ -57,23 +79,37 @@ class BleManager {
}
}
/**
* Disconnects a peripheral by its identifier.
*/
suspend fun disconnect(identifier: String) {
mutex.withLock {
peripherals.remove(identifier)?.disconnect()
}
}
/**
* Disconnects all currently active BLE connections.
*/
suspend fun disconnectAll() {
mutex.withLock {
peripherals.values.forEach { it.disconnect() }
peripherals.clear()
}
}
/**
* Sends a UART command string to the connected peripheral.
*/
suspend fun sendCommand(peripheral: Peripheral, command: String) {
Napier.d(">>>>> Sent to uart: $command")
val cmd = command.replace("\\n", "\n")
peripheral.write(RX_CHAR, cmd.encodeToByteArray())
}
/**
* Returns a flow that emits incoming UART data from the peripheral.
*/
fun observeRx(peripheral: Peripheral): Flow<String> =
peripheral.observe(TX_CHAR).map { it.decodeToString() }
}
@@ -3,13 +3,24 @@ package com.digitoolsolutions.app.torquevaultkmp.screens
import androidx.compose.ui.graphics.vector.ImageVector
import org.jetbrains.compose.resources.DrawableResource
/**
* Wrapper for different types of icons used in the navigation system.
*/
sealed class AppIcon {
/** Uses a compiled Compose Multiplatform resource. */
data class Resource(val resId: DrawableResource) : AppIcon()
/** Uses a standard Material [ImageVector]. */
data class Vector(val imageVector: ImageVector) : AppIcon()
}
/**
* Base class for all navigation destinations within the app.
*
* Each destination defines its display [label], associated [icon], and unique [route].
*/
open class Destinations(
val label: String,
val icon: AppIcon,
val route: String = label.lowercase().replace(" ", "_")
)
)
@@ -8,6 +8,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Represents the state of the login process.
*/
sealed class LoginUiState {
object Idle : LoginUiState()
object Loading : LoginUiState()
@@ -15,21 +18,39 @@ sealed class LoginUiState {
data class Error(val message: String) : LoginUiState()
}
/**
* ViewModel for the Login screen.
*
* Handles user authentication and server URL configuration during the login process.
*/
class AuthViewModel(
private val authApi: AuthApi,
private val tokenManager: TokenManager
) : ViewModel() {
/**
* Observable state of the login operation.
*/
private val _loginState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
val loginState = _loginState.asStateFlow()
/**
* The current server URL entered by the user or loaded from storage.
*/
private val _serverUrl = MutableStateFlow(tokenManager.getBaseUrl() ?: "http://digitoolsolutions.synology.me:3000")
val serverUrl = _serverUrl.asStateFlow()
/**
* Updates the server URL in the UI state.
*/
fun onChangeServerUrl(url: String) {
_serverUrl.value = url
}
/**
* Attempts to log in the user with the provided credentials.
* Before logging in, it saves the current [serverUrl] to [TokenManager].
*/
fun login(username: String, password: String) {
viewModelScope.launch {
_loginState.value = LoginUiState.Loading
@@ -68,6 +68,9 @@ import torquevaultkmp.composeapp.generated.resources.lbl_server_url
import torquevaultkmp.composeapp.generated.resources.lbl_username
import torquevaultkmp.composeapp.generated.resources.server_url_placeholder
/**
* Screen for user authentication.
*/
@Composable
fun LoginScreen(
navController: NavController
@@ -92,6 +95,9 @@ fun LoginScreen(
)
}
/**
* Stateless UI content for the login screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreenContent(
@@ -10,16 +10,27 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.random.Random
/**
* ViewModel for the Bonded Devices screen.
*
* Manages the list of devices that have been previously connected and saved
* to the local database, supporting cursor-based pagination.
*/
class BondedViewModel(
private val deviceRepository: DeviceRepository
) : ViewModel() {
/**
* Observable list of bonded devices.
*/
private val _bondedDevices = MutableStateFlow<List<Device>>(emptyList())
val bondedDevices: StateFlow<List<Device>> = _bondedDevices.asStateFlow()
private var lastId: String? = null
private val pageSize = DeviceRepository.PAGE_SIZE
private var isLastPage = false
/** Indicates if a background data fetch is currently in progress. */
var isLoading = MutableStateFlow(false)
private set
@@ -27,6 +38,9 @@ class BondedViewModel(
loadNextPage()
}
/**
* Fetches the next page of bonded devices from the database.
*/
fun loadNextPage() {
if (isLoading.value || isLastPage) return
@@ -27,6 +27,12 @@ import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.action_cancel
import torquevaultkmp.composeapp.generated.resources.action_retry
/**
* Screen responsible for real-time UART communication with a connected BLE device.
*
* It manages different UI states based on the connection status (Connecting, Connected, Disconnected)
* and provides a terminal-like interface for sending and receiving messages.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UartCommunicationScreen(
@@ -122,4 +128,4 @@ fun UartCommunicationScreen(
}
}
}
}
}
@@ -29,6 +29,11 @@ import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.home_title
import kotlin.uuid.ExperimentalUuidApi
/**
* The landing screen after a successful login.
*
* Displays currently connected devices and provides a gateway to communicate with them.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class)
@Composable
fun HomeScreen(
@@ -12,11 +12,24 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* ViewModel for the Home Screen.
*
* Manages the fetching and display of the list of work orders available
* for processing.
*/
class HomeViewModel(
private val fetchWorkOrdersUseCase: FetchWorkOrdersUseCase
): ViewModel() {
/**
* Observable list of work orders.
*/
private val _workOrders = MutableStateFlow<List<WorkOrder>>(emptyList())
val workOrders = _workOrders.asStateFlow()
/**
* Loads the initial page of work orders.
*/
fun loadWorkOrders() {
viewModelScope.launch {
try {
@@ -62,6 +62,12 @@ import org.koin.compose.viewmodel.koinViewModel
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.log_title
/**
* Screen used to view, filter, and export diagnostic logs.
*
* Logs are displayed in chronological order (newest first) and can be filtered by source
* (System or specific Device). Users can also export logs as Plain Text or CSV files.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogScreen(
@@ -80,6 +86,9 @@ fun LogScreen(
)
}
/**
* Stateless UI content for the diagnostic logs screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun LogContent(
@@ -29,15 +29,27 @@ import com.digitoolsolutions.app.torquevaultkmp.utils.NetworkStatus
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
import org.koin.compose.koinInject
/**
* Data class representing the current state of system requirements.
*/
data class Requirements(
/** Whether the device supports Bluetooth Low Energy. */
val hasBleFeature: Boolean,
/** Whether Bluetooth is currently turned on. */
val isEnabled: Boolean,
/** Whether the app has been granted necessary Bluetooth/Location permissions. */
val hasPermission: Boolean,
/** Whether the user has permanently denied the required permissions. */
val hasPermanentlyDenied: Boolean,
/** Whether Location services are enabled (required for BLE on some Android versions). */
val isLocationEnabled: Boolean,
/** Whether the device has active internet connectivity. */
val networkAvailable: Boolean,
)
/**
* Sealed class representing the mutually exclusive states of requirement fulfillment.
*/
sealed class RequirementState {
object BleUnsupported : RequirementState()
object BluetoothDisabled : RequirementState()
@@ -48,6 +60,9 @@ sealed class RequirementState {
object Ready : RequirementState()
}
/**
* Maps the raw [Requirements] data into a simplified [RequirementState].
*/
fun Requirements.toState(): RequirementState {
return when {
!hasBleFeature -> RequirementState.BleUnsupported
@@ -64,6 +79,16 @@ fun Requirements.toState(): RequirementState {
else -> RequirementState.Ready
}
}
/**
* A wrapper component that ensures all system requirements (BLE, Permissions, Location, Network)
* are met before allowing interaction with the main application content.
*
* It displays an overlay if any requirement is missing and provides actions to resolve them.
*
* @param onReady Callback triggered when all requirements are satisfied.
* @param onPause Callback triggered when a requirement is lost.
* @param content The main application content to be displayed (usually behind the requirement overlay).
*/
@Composable
fun RequirementWrapper(
onReady: () -> Unit,
@@ -33,6 +33,12 @@ import torquevaultkmp.composeapp.generated.resources.scan_empty_title
import torquevaultkmp.composeapp.generated.resources.scanner_title
import kotlin.uuid.ExperimentalUuidApi
/**
* Screen used to discover nearby Bluetooth Low Energy devices.
*
* Users can initiate scans, view a list of found devices with their signal strength (RSSI),
* and tap on a device to connect or navigate to its communication interface.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class)
@Composable
fun ScannerScreen(
@@ -113,12 +119,11 @@ fun ScannerScreen(
}
}
}
// LaunchedEffect(Unit) {
// viewModel.startScan()
// }
}
/**
* A list item representing a discovered Bluetooth device.
*/
@OptIn(ExperimentalUuidApi::class)
@Composable
fun DeviceListItem(
@@ -111,8 +111,14 @@ class ScannerViewModel(
private var stabilized = false
/**
* Record advertisement: collect (20 times) intervals between consecutive ads
* to calibrate the cleanup expiration time when a device stops advertising.
* Records advertisement intervals to calibrate the cleanup expiration time.
*
* Digital torque wrenches may have different advertising intervals. By observing the
* time delta between consecutive advertisements, the app calculates a dynamic
* [ADV_EXPIRATION_TIME]. This ensures that when a device stops advertising,
* it is removed from the list promptly but without flickering.
*
* Calibration stabilizes after collecting 20 samples from a single device.
*/
private fun calibrateCleanupTime(deviceId: String, now: TimeMark) {
if (stabilized) return
@@ -9,36 +9,60 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* ViewModel for the Settings screen.
*
* Allows users to configure application-wide preferences such as server URL,
* authentication tokens, and automatic reconnection settings.
*/
class SettingViewModel(
private val tokenManager: TokenManager,
private val storage: AppStorage
) : ViewModel() {
/** The base server URL. */
private val _serverUrl = MutableStateFlow(tokenManager.getBaseUrl() ?: "")
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
/** The current refresh token. */
private val _refreshToken = MutableStateFlow(tokenManager.getRefreshToken() ?: "")
val refreshToken: StateFlow<String> = _refreshToken.asStateFlow()
/** Whether the app should automatically attempt to connect to known devices. */
private val _autoConnect = MutableStateFlow(storage.load<Boolean>(ReferKeys.DEVICE_AUTO_CONNECT) ?: false)
val autoConnect: StateFlow<Boolean> = _autoConnect.asStateFlow()
/**
* Updates the server URL in the UI state.
*/
fun updateServerUrl(url: String) {
_serverUrl.value = url
}
/**
* Updates the refresh token in the UI state.
*/
fun updateRefreshToken(token: String) {
_refreshToken.value = token
}
/**
* Persists the current server URL and tokens to storage.
*/
fun saveSettings() {
tokenManager.saveServerUrl(_serverUrl.value)
tokenManager.saveTokens("", _refreshToken.value)
}
/**
* Clears all authentication tokens and logs the user out.
*/
fun logout() {
tokenManager.clearTokens()
}
/**
* Updates the auto-connect preference and persists it.
*/
fun updateAutoConnect(enabled: Boolean) {
_autoConnect.value = enabled
storage.save(ReferKeys.DEVICE_AUTO_CONNECT, enabled)
@@ -2,10 +2,23 @@ package com.digitoolsolutions.app.torquevaultkmp.utils
import io.ktor.http.HttpStatusCode
/**
* Exception thrown when a remote API request fails with a non-success status code.
*
* @property status The HTTP status code returned by the server.
* @property body The raw response body containing error details.
*/
class ApiException(val status: HttpStatusCode, val body: String) : Exception(
"API error ${status.value}: $body"
)
/**
* Exception used internally to trigger a token refresh and request retry.
*/
class RetryWithNewTokenException : Exception(">>>>> Access token expired, retry with new token")
class ForceLogoutException : Exception(">>>>> Refresh token expired, force logout")
/**
* Exception used to signal that both access and refresh tokens are invalid,
* requiring the user to re-authenticate.
*/
class ForceLogoutException : Exception(">>>>> Refresh token expired, force logout")
@@ -1,5 +1,11 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
/**
* Interface providing basic application metadata.
*/
interface AppInfo {
/**
* The human-readable version name of the application (e.g., "1.0.2").
*/
val version: String?
}
}
@@ -2,14 +2,42 @@ package com.digitoolsolutions.app.torquevaultkmp.utils
import kotlinx.coroutines.flow.StateFlow
/**
* Interface defining the platform-specific operations for managing Bluetooth state and permissions.
*/
interface BluetoothManager {
/**
* Observable state indicating if Bluetooth is currently enabled on the device.
*/
val isBluetoothEnabled: StateFlow<Boolean>
/**
* Observable state indicating if the app has the necessary Bluetooth permissions.
*/
val hasBluetoothPermission: StateFlow<Boolean>
/**
* Triggers a check of the current Bluetooth hardware state.
*/
fun checkBluetoothState()
/**
* Triggers a check of the current Bluetooth permission status.
*/
fun checkBluetoothPermission()
/**
* Opens the system settings for the application, allowing the user to grant permissions.
*/
fun openSettings()
/**
* Requests the user to enable Bluetooth if it is currently disabled.
*/
fun enableBluetooth()
}
/**
* Factory function to create a platform-specific [BluetoothManager].
*/
expect fun createBluetoothManager(): BluetoothManager
@@ -1,16 +1,28 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
/**
* Utility object containing protocol constants and helper functions for
* decoding and encoding data exchanged with torque devices.
*/
object Helper {
const val START_CHARACTER ="$"
const val END_LINE_FEED = "#\n"
const val SECURITY_CRC16 = "00cr16"
const val CMD_EMPTY_WO = "START_WO;empty\n$SECURITY_CRC16"
/** Command sent to indicate no work orders are available. */
const val CMD_EMPTY_WO = "START_WO;empty"
const val WO_RES = "WO_RES"
val CMD_RES_OK: (String) -> String = { cmd -> "$cmd;OK\n$SECURITY_CRC16" }
val CMD_RES_NG: (String) -> String = { cmd -> "$cmd;NG\n$SECURITY_CRC16" }
/** Generates a success response for a specific command. */
val CMD_RES_OK: (String) -> String = { cmd -> "$cmd;OK" }
/** Generates a failure response for a specific command. */
val CMD_RES_NG: (String) -> String = { cmd -> "$cmd;NG" }
const val BLE_NUTS_POS = 2
const val BLE_TORQUE_POS = BLE_NUTS_POS + 1
/** Commands recognized by the torque device protocol. */
enum class Command {
START_WO,
NEXT_WO,
@@ -22,6 +34,8 @@ object Helper {
try { valueOf(code) } catch (e: IllegalArgumentException) { null }
}
}
/** Categories of devices supported by the protocol. */
enum class TypeOfDevice(val code: Int) {
WHEEL(0),
OIL_FILTER(1),
@@ -36,11 +50,17 @@ object Helper {
}
}
}
/** Action codes sent by the device during the finish workflow. */
object TorqueAction {
const val TORQUE = "0"
const val RE_TORQUE = "1"
const val CANCEL = "2"
}
/**
* Represents a decoded message received from a torque device.
*/
data class RxMessage(
val command: String,
val type: String, // Type of device
@@ -49,12 +69,24 @@ object Helper {
val securityCode: String,
val crc16: String
)
/**
* Wraps a command string with the protocol's start, security, crc16 and end characters.
*/
fun buildUartCommand(cmd: String): String {
return "$START_CHARACTER$cmd$END_LINE_FEED"
return "$START_CHARACTER$cmd\n$SECURITY_CRC16$END_LINE_FEED"
}
/**
* Decodes a raw string message received from the device into an [RxMessage].
*
* The protocol format is: $COMMAND;TYPE;[WOID;DATA...]\nSECURITYCRC#\n
*
* @return The decoded message or null if the format is invalid.
*/
fun decodeRxData(msg: String): RxMessage? {
/** Check header and footer*/
if (!msg.startsWith("$") && !msg.endsWith("#\n")) return null
if (!msg.startsWith("$") || !msg.endsWith("#\n")) return null
/** Remove $ and trim end */
val payload = msg.drop(1).trimEnd('*', '#',' ', '\r', '\n')
val parts = payload.split(";", limit = 3)
@@ -75,6 +107,13 @@ object Helper {
return RxMessage(cmd, type, woId, data, security, crc16)
}
/**
* Parses the torque result data for multiple wheels and nuts.
*
* @param torqueData List of strings containing wheel names followed by nut torque values.
* @param nutsPerWheel The expected number of nuts for each wheel.
* @return A map where keys are wheel identifiers and values are lists of torque measurements.
*/
fun parseWheelTorqueData(torqueData: List<String>, nutsPerWheel: Int): Map<String, List<Double>> {
val result = mutableMapOf<String, List<Double>>()
var i = 0
@@ -93,6 +132,10 @@ object Helper {
}
return result
}
/**
* Standardizes device identifiers (MAC addresses or UUIDs) for display.
*/
fun formatDeviceId(deviceId: String?): String {
if(deviceId == null) return ""
return if (deviceId.contains(":")) {
@@ -2,12 +2,29 @@ package com.digitoolsolutions.app.torquevaultkmp.utils
import kotlinx.coroutines.flow.StateFlow
/**
* Represents the connectivity status of the device.
*/
enum class NetworkStatus {
Available, Unavailable
}
/**
* Interface for monitoring real-time network connectivity changes.
*/
interface NetworkMonitor {
/**
* Observable stream of the current [NetworkStatus].
*/
val status: StateFlow<NetworkStatus>
/**
* Starts the network monitoring process.
*/
fun start()
/**
* Stops the network monitoring process.
*/
fun stop()
}
}
@@ -3,7 +3,11 @@ package com.digitoolsolutions.app.torquevaultkmp.utils
import io.github.aakira.napier.Antilog
import io.github.aakira.napier.LogLevel
/**
* A silent logger implementation for Napier that discards all log messages.
* Used in production builds or specific scenarios where console logging is not desired.
*/
class NoOpAntilog : Antilog() {
override fun isEnable(priority: LogLevel, tag: String?): Boolean = false
override fun performLog(priority: LogLevel, tag: String?, throwable: Throwable?, message: String?) {}
}
}
@@ -3,14 +3,40 @@ package com.digitoolsolutions.app.torquevaultkmp.utils
import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.StateFlow
/**
* Interface defining platform-specific logic for handling runtime permissions and hardware features.
*/
interface PermissionHandler {
/**
* Composable function that encapsulates the logic for requesting permissions.
*
* @param onPermissionResult Callback invoked with `true` if permissions were permanently denied.
* @param content Composable providing a function to trigger the permission request.
*/
@Composable
fun HandlePermissionRequest(
onPermissionResult: (Boolean) -> Unit,
content: @Composable (requestPermissions: () -> Unit) -> Unit
)
/**
* Indicates if the device hardware supports Bluetooth Low Energy.
*/
val hasBleFeature: Boolean
/**
* Observable state indicating if Location services are currently enabled.
* (Location is often a prerequisite for BLE scanning on Android).
*/
val isLocationEnabled: StateFlow<Boolean>
/**
* Requests the user to enable Location services.
*/
fun enableLocation()
/**
* Triggers a check of the current Location service state.
*/
fun checkLocationState()
}
@@ -1,6 +1,16 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
/**
* Interface defining platform-specific logic for sharing log files.
*/
interface ShareLog {
/**
* Exports and shares the provided [content] as a plain text file.
*/
fun exportTextFile(content: String)
/**
* Exports and shares the provided [content] as a CSV file.
*/
fun exportCSVFile(content: String)
}
}
@@ -10,6 +10,12 @@ import platform.darwin.NSObject
import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue
/**
* iOS implementation of [BluetoothManager] using CoreBluetooth.
*
* This class monitors the CBCentralManager state to track Bluetooth availability
* and manages permission status on iOS devices.
*/
class IosBluetoothManager : BluetoothManager {
private val _isBluetoothEnabled = MutableStateFlow(false)
override val isBluetoothEnabled: StateFlow<Boolean> = _isBluetoothEnabled.asStateFlow()