Update Readme.md, add docstring
This commit is contained in:
+6
@@ -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(
|
||||
|
||||
+31
@@ -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
|
||||
}
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
+10
@@ -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(
|
||||
|
||||
+7
-1
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+13
@@ -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 {
|
||||
|
||||
+4
-1
@@ -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/"
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
-1
@@ -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(
|
||||
|
||||
+5
-1
@@ -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
-3
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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)
|
||||
}
|
||||
|
||||
+23
@@ -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
|
||||
|
||||
+18
-3
@@ -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)
|
||||
}
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+4
-1
@@ -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> {
|
||||
|
||||
+22
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -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"
|
||||
}
|
||||
|
||||
+12
@@ -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)
|
||||
|
||||
+15
@@ -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()) }
|
||||
|
||||
+21
-6
@@ -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,
|
||||
|
||||
+10
@@ -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)
|
||||
}
|
||||
|
||||
+36
@@ -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() }
|
||||
}
|
||||
|
||||
+12
-1
@@ -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(" ", "_")
|
||||
)
|
||||
)
|
||||
|
||||
+21
@@ -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
|
||||
|
||||
+6
@@ -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(
|
||||
|
||||
+14
@@ -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
|
||||
|
||||
|
||||
+7
-1
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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(
|
||||
|
||||
+13
@@ -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 {
|
||||
|
||||
+9
@@ -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(
|
||||
|
||||
+25
@@ -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,
|
||||
|
||||
+9
-4
@@ -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(
|
||||
|
||||
+8
-2
@@ -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
|
||||
|
||||
+24
@@ -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)
|
||||
|
||||
+14
-1
@@ -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")
|
||||
|
||||
+7
-1
@@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -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
|
||||
|
||||
+48
-5
@@ -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(":")) {
|
||||
|
||||
+18
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -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?) {}
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -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()
|
||||
}
|
||||
|
||||
+11
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user