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
@@ -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)
}
}