Compare commits

...
13 Commits
Author SHA1 Message Date
lamtruong28 399113e3f1 Fix 3
Build APK file / Verify environment (push) Successful in 6s
Sync Source to Host / sync-source (push) Successful in 6s
Build APK file / Build APK file (push) Successful in 4m52s
2026-08-19 21:58:02 +07:00
lamtruong28 1f5c5d7557 Fix 2
Build APK file / Verify environment (push) Successful in 5s
Sync Source to Host / sync-source (push) Successful in 6s
Build APK file / Build APK file (push) Failing after 4m22s
2026-08-19 20:44:27 +07:00
lamtruong28 3fb07872c2 Fix
Build APK file / Verify environment (push) Successful in 4s
Sync Source to Host / sync-source (push) Successful in 8s
Build APK file / Build APK file (push) Failing after 1m33s
2026-08-19 20:34:48 +07:00
lamtruong28 f2ba761cc1 Add all files
Build APK file / Verify environment (push) Successful in 4s
Build APK file / Build APK file (push) Failing after 1m32s
2026-08-19 20:28:56 +07:00
lamtruong28 e94c3f5c61 Adjust screenshots size 2026-08-19 19:57:06 +07:00
lamtruong28 67ef6c8aa7 Write user guide docs 2026-08-19 19:50:15 +07:00
lamtruong28 2b4e4f86b2 Store show logs preference in config 2026-08-18 18:59:06 +07:00
lamtruong28 0ee4e742c9 Automation e2e test with maestro 2026-08-18 17:40:02 +07:00
lamtruong28 c8aac2f6db Implement sign in with api key 2026-07-30 14:54:04 +07:00
lamtruong28 c6cb6b0bcc Write Release-ios-xCode.md file 2026-07-29 13:53:53 +07:00
lamtruong28 c75d8de4b4 Write unit test 2026-07-29 13:53:15 +07:00
lamtruong28 557681cbeb Update Readme.md, add docstring 2026-07-23 13:20:13 +07:00
lamtruong28 8af98f505f Intergating new protocol 2026-07-18 12:49:48 +07:00
80 changed files with 1626 additions and 243 deletions
+85
View File
@@ -0,0 +1,85 @@
name: Build APK file
run-name: ${{ gitea.actor }} is building APK file
on:
push:
jobs:
verify:
name: "Verify environment"
runs-on: android
steps:
- name: Debug Environment
run: |
whoami
cat /etc/os-release
pwd
which java || true
env | sort
- name: Check Java
run: |
java -version
echo "JAVA_HOME=$JAVA_HOME"
- name: Check Android SDK
run: |
echo "ANDROID_HOME=$ANDROID_HOME"
echo "PATH=$PATH"
which sdkmanager || true
sdkmanager --version || true
ls -la $ANDROID_HOME || true
build-android:
name: "Build APK file"
runs-on: android
needs: verify
steps:
- uses: actions/checkout@v4
- name: Grant execute permission
run: chmod +x ./gradlew
- name: List Gradle Projects
run: ./gradlew projects
- name: Build Android APK
run: ./gradlew :composeApp:assembleDebug --stacktrace
- name: Find location APK file
run: |
find . -name "*.apk"
- name: Show APK
run: |
ls -lah composeApp/build/outputs/apk/debug/
# - uses: actions/upload-artifact@v4
# with:
# name: TorqueVault-apk
# path: composeApp/build/outputs/apk/debug/androidApp-debug.apk
- name: Build Package Version
shell: bash
run: |
VERSION_NAME=$(grep -R "versionName" composeApp/build.gradle.kts \
| head -1 \
| sed 's/.*"\(.*\)".*/\1/')
SHORT_SHA=$(git rev-parse --short HEAD)
PACKAGE_VERSION="${VERSION_NAME}+${SHORT_SHA}"
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_ENV
echo "Package Version: $PACKAGE_VERSION"
- name: Upload APK to Package Registry
run: |
curl \
--fail \
--user "${{ github.actor }}:${{ secrets.PACKAGE_TOKEN }}" \
--upload-file composeApp/build/outputs/apk/debug/composeApp-debug.apk \
"${{ github.server_url }}/api/packages/${{ github.repository_owner }}/generic/torque-vault/${PACKAGE_VERSION}/Torque-Vault.apk"
+30
View File
@@ -0,0 +1,30 @@
name: Sync Source to Host
run-name: ${{ gitea.actor }} is synchronizing the source code to the host
on:
push:
jobs:
sync-source:
runs-on: ubuntu-latest
steps:
- name: Sync repository
run: |
REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
REPO_NAME=$(basename "${GITHUB_REPOSITORY}")
TARGET_DIR="/data/${REPO_NAME}"
BRANCH="${GITHUB_REF_NAME}"
if [ ! -d "$TARGET_DIR/.git" ]; then
echo "Cloning repository..."
git clone -b "$BRANCH" "$REPO_URL" "$TARGET_DIR"
else
echo "Updating repository..."
git -C "$TARGET_DIR" fetch origin "$BRANCH"
git -C "$TARGET_DIR" checkout "$BRANCH"
git -C "$TARGET_DIR" reset --hard "origin/$BRANCH"
fi
echo "Current commit:"
git -C "$TARGET_DIR" log --oneline -1
+7
View File
@@ -0,0 +1,7 @@
flows:
- flows/*
executionOrder:
continueOnFailure: false
flowsOrder:
- login
+10
View File
@@ -0,0 +1,10 @@
appId: com.digitoolsolutions.app.torquevaultkmp
---
- runFlow:
file: ../subflows/launch-app.yaml
- runFlow:
file: ../subflows/login-steps.yaml
- runFlow:
file: ../subflows/communicate-steps.yaml
+6
View File
@@ -0,0 +1,6 @@
appId: com.digitoolsolutions.app.torquevaultkmp
---
- runFlow:
file: ../subflows/launch-app.yaml
- runFlow:
file: ../subflows/login-steps.yaml
+6
View File
@@ -0,0 +1,6 @@
appId: com.digitoolsolutions.app.torquevaultkmp
---
#- runFlow:
# file: ../subflows/launch-app.yaml
- runFlow:
file: ../subflows/communicate-steps.yaml
+32
View File
@@ -0,0 +1,32 @@
appId: com.digitoolsolutions.app.torquevaultkmp
---
- tapOn: "Scanner"
- extendedWaitUntil:
visible:
text: "3006"
timeout: 30000 # 30s
- tapOn:
text: "3006"
delay: 1500
- waitForAnimationToEnd:
timeout: 1000
# In case of empty work order
# ---------------------------
#- tapOn:
# text: "Back"
# delay: 500
#
#- assertVisible: "Scanner"
#- waitForAnimationToEnd:
# timeout: 1000
#- extendedWaitUntil:
# visible:
# text: "3006"
# timeout: 30000 # 30s
# In case of work orders
# --------------------------
- tapOn: "Manual Command"
- inputText: "ACCEPT_WO;NG"
- tapOn: "Send"
+16
View File
@@ -0,0 +1,16 @@
appId: com.digitoolsolutions.app.torquevaultkmp
---
- runFlow:
file: ../subflows/launch-app.yaml
- runFlow:
when:
visible: "Enable"
commands:
- tapOn: "Enable"
- runFlow:
when:
visible: "Cho phép"
commands:
- tapOn: "Cho phép"
+6
View File
@@ -0,0 +1,6 @@
appId: com.digitoolsolutions.app.torquevaultkmp
---
- launchApp:
clearState: true
- waitForAnimationToEnd:
timeout: 3000
+24
View File
@@ -0,0 +1,24 @@
appId: com.digitoolsolutions.app.torquevaultkmp
---
#- longPressOn: "Username"
#- tapOn:
# text: "Select All"
# optional: true
#- tapOn:
# text: "Chọn tất cả"
# optional: true
#- eraseText: 1
#- inputText: "admin"
#- tapOn: "App Logo"
#- longPressOn: "Password"
#- tapOn:
# text: "Select All"
# optional: true
#- tapOn:
# text: "Chọn tất cả"
# optional: true
#- eraseText: 1
#- inputText: "admin"
#- tapOn: "App Logo"
- tapOn: "Sign In"
- assertVisible: "Home"
+64 -27
View File
@@ -1,35 +1,72 @@
This is a Kotlin Multiplatform project targeting Android, iOS.
# TorqueVaultKMP
* [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications.
It contains several subfolders:
- [commonMain](./composeApp/src/commonMain/kotlin) is for code thats common for all targets.
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
For example, if you want to use Apples CoreCrypto for the iOS part of your Kotlin app,
the [iosMain](./composeApp/src/iosMain/kotlin) folder would be the right place for such calls.
Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./composeApp/src/jvmMain/kotlin)
folder is the appropriate location.
TorqueVaultKMP is a Kotlin Multiplatform (KMP) application designed for managing torque measurements, connecting to digital torque devices via Bluetooth Low Energy (BLE), and handling work orders.
* [/iosApp](./iosApp/iosApp) contains iOS applications. Even if youre sharing your UI with Compose Multiplatform,
you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
The project targets **Android** and **iOS** using **Compose Multiplatform** for a shared UI and logic.
### Build and Run Android Application
## 🚀 Key Features
To build and run the development version of the Android app, use the run configuration from the run widget
in your IDEs toolbar or build it directly from the terminal:
- on macOS/Linux
```shell
./gradlew :composeApp:assembleDebug
```
- on Windows
```shell
.\gradlew.bat :composeApp:assembleDebug
```
- **BLE Device Management**: Scan, connect, and bond with digital torque wrenches using the [Kable](https://github.com/JuulLabs/kable) library.
- **Adaptive Scanning**: Automatically calibrates device discovery expiration by observing advertisement intervals, ensuring a responsive UI when devices stop broadcasting.
- **Work Order Workflow**:
- Fetch "Open" work orders from a remote API.
- Synchronize work order details (License Plate, Make, Torque requirements) with connected BLE devices.
- Receive and upload measurement results (Torque values, wheel/nut indices) back to the server.
- **Bonding & Persistence**: Automatically remembers and auto-connects to bonded devices using **SQLDelight** for local storage.
- **Diagnostic Logging**: A dedicated in-app logging system (`LogRepository`) that tracks system events, BLE communication, and HTTP requests. Supports exporting logs as CSV or Text for remote debugging.
- **Theme Support**: Built-in support for Dark and Light modes.
### Build and Run iOS Application
## 🛠 Tech Stack
To build and run the development version of the iOS app, use the run configuration from the run widget
in your IDEs toolbar or open the [/iosApp](./iosApp) directory in Xcode and run it from there.
- **Kotlin**: `2.3.21`
- **UI Framework**: [Compose Multiplatform](https://www.jetbrains.com/lp/compose-multiplatform/) `1.11.0`
- **Dependency Injection**: [Koin](https://insert-koin.io/) `4.2.1`
- **Networking**: [Ktor](https://ktor.io/) `3.4.3`
- **Database**: [SQLDelight](https://cashapp.github.io/sqldelight/) `2.3.2`
- **Bluetooth**: [Kable](https://github.com/JuulLabs/kable) `0.43.0`
- **Logging**: [Napier](https://github.com/aakira/Napier) `2.7.1`
- **Time/Date**: [Kotlinx Datetime](https://github.com/Kotlin/kotlinx-datetime) `0.8.0`
- **Serialization**: [Kotlinx Serialization](https://github.com/Kotlin/kotlinx.serialization) `2.3.21`
## 📁 Project Structure
- `composeApp/src/commonMain`: Shared logic, UI, and data management.
- `screens/`: UI modules for Scanner, Logs, Home, Settings, and Auth.
- `data/`: Network DTOs, SQLDelight DB schema, and Repositories.
- `kable/`: BLE abstraction and manager.
- `domain/`: Business logic models and shared interfaces.
- `composeApp/src/androidMain`: Android-specific implementations (e.g., Bluetooth permissions, Splash screen).
- `composeApp/src/iosMain`: iOS-specific implementations (e.g., Native sharing sheets, CoreBluetooth delegates).
- `iosApp`: Entry point for the iOS application.
## 🏗 Getting Started
### Prerequisites
- Android Studio (Ladybug or newer) / IntelliJ IDEA.
- Xcode 15+ (for iOS development).
- Kotlin Multiplatform plugin.
### Build and Run
#### Android
```bash
./gradlew :composeApp:assembleDebug
```
#### iOS
Open the `iosApp` directory in Xcode or use the run configuration in Android Studio.
## ⚙️ Communication Protocol
The app uses a custom UART protocol to communicate with torque devices. Commands include:
- `START_WO`: Initiate work order fetch.
- `ACCEPT_WO`: Confirm a specific work order selection.
- `FINISH_WO`: Send measurement data back to the app for server upload.
## 📜 Logging
Logs are stored in memory (up to 1000 entries) and categorized into **System** and **Device** logs. They can be shared via the **Logs** screen using platform-native sharing components.
---
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)…
Developed by **Digitool Solutions**.
+91
View File
@@ -0,0 +1,91 @@
# iOS Build and Release Guide - TorqueVaultKMP
This document provides step-by-step instructions to set up the environment and release the iOS application from a Kotlin Multiplatform (KMP) project to the App Store.
## 1. Environment Setup on macOS
### Setup JDK 17
The project requires JDK 17 to run Gradle tasks and build the iOS framework.
1. **Check current Java version:**
```bash
java -version
```
If it returns `openjdk version "xx.x.x"`, you can skip to **Install Xcode**.
2. **Install via Homebrew (if not installed):**
```bash
brew install openjdk@17
```
3. **Configure Environment Variables:**
Run the following commands to link the JDK and update your `.zshrc`:
```bash
sudo ln -sfn /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk
echo 'export PATH="/opt/homebrew/opt/openjdk@17/bin:$PATH"' >> ~/.zshrc
echo 'export JAVA_HOME=$(/usr/libexec/java_home -v 17)' >> ~/.zshrc
source ~/.zshrc
```
### Setup Xcode
1. **Check if Xcode is installed:**
Ensure you have the latest version of Xcode from the App Store.
2. **Check Command Line Tools:**
```bash
xcode-select -p
```
If it returns a path, tools are installed. Otherwise, run: `xcode-select --install`.
## 2. Xcode Project Configuration
1. **Open the Project:**
Open `/iosApp/iosApp.xcodeproj` in Xcode.
2. **Setup Signing & Capabilities:**
- Select the **iosApp** project in the Project Navigator.
- Go to the **Signing & Capabilities** tab.
- Select your **Team** (Apple Developer Account).
- Verify the **Bundle Identifier** (must match the ID registered in App Store Connect, e.g., `com.digitoolsolutions.app.torquevaultkmp`).
3. **Update Version and Build:**
- In the **General** tab, update:
- **Version**: e.g., `1.0.2` (Should match `versionName` in `composeApp/build.gradle.kts`).
- **Build**: e.g., `2` (Should match `versionCode` in `composeApp/build.gradle.kts` and must be incremented for each upload).
---
## 3. Build and Upload Process
### Step 1: Clean Project
Before building the release version, perform a clean directly in Xcode to avoid cache issues:
- Go to **Product** -> **Clean Build Folder** (or press `Shift + Command + K`).
- *Note: This will also trigger a clean for the Kotlin framework if configured in the build script.*
### Step 2: Create Archive in Xcode
1. In Xcode, select the build target as **Any iOS Device (arm64)** from the toolbar.
2. Go to **Product** -> **Archive**.
3. Xcode will start building. The Kotlin framework (`ComposeApp`) will be automatically compiled via the Gradle build phase script.
### Step 3: Upload to App Store Connect
1. Once the Archive is complete, the **Organizer** window will appear.
2. Select the latest build and click **Distribute App**.
3. Choose **App Store Connect** and then **Upload**.
4. Follow the prompts (Validate, Re-sign).
5. Wait for the success notification.
---
## 4. App Store Connect Management
1. Log in to [App Store Connect](https://appstoreconnect.apple.com/).
2. Select your app **TorqueVault**.
3. Check the build status in the **TestFlight** tab to ensure it has finished processing.
4. When ready, go back to the **App Store** tab, select the build, and click **Submit for Review**.
## Important Notes
- **Framework Name**: The main framework is named `ComposeApp`. If you change `baseName` in `build.gradle.kts`, you must update the Xcode configuration accordingly.
- **Permissions**: Ensure all required permissions (Bluetooth, Camera, etc.) are declared in `Info.plist`.
- **Dependencies**: If the project uses native libraries via CocoaPods or Swift Package Manager, ensure they are correctly installed before building.
+2 -1
View File
@@ -27,6 +27,7 @@ kotlin {
baseName = "ComposeApp"
isStatic = false
linkerOpts("-lsqlite3")
freeCompilerArgs += listOf("-Xbinary=bundleId=com.digitoolsolutions.app.composeapp")
}
}
@@ -88,7 +89,7 @@ android {
applicationId = "com.digitoolsolutions.app.torquevaultkmp"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 2
versionCode = 4
versionName = "1.0.2"
}
packaging {
@@ -1,14 +1,12 @@
package com.digitoolsolutions.app.torquevaultkmp
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi
import com.digitoolsolutions.app.torquevaultkmp.data.storage.ReferKeys
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.Endpoints
import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager
import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException
import io.github.aakira.napier.Napier
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
@@ -17,9 +15,6 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.plugin
import io.ktor.client.request.header
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
@@ -45,6 +40,9 @@ actual fun createPlatformHttpClient(
} else null
}
refreshTokens {
if (response.call.request.url.encodedPath.contains(Endpoints.AUTH_TOKEN)) {
return@refreshTokens null
}
Napier.d(">>>>> [NetworkEngine.android.kt] Access token expired, auto request refresh token...")
val newAccess = authApi?.refreshToken()
if (newAccess != null) {
@@ -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
@@ -19,11 +19,15 @@
<string name="dark_theme">Dark theme</string>
<string name="auto_connect">Auto-connect device</string>
<string name="lbl_server_url">Server URL</string>
<string name="lbl_server_url">Server Address</string>
<string name="server_url_placeholder">Ex: https://example.com</string>
<string name="lbl_username">Username</string>
<string name="lbl_password">Password</string>
<string name="btn_login">Login</string>
<string name="btn_login">Sign In</string>
<string name="api_key">API Key</string>
<string name="use_api_key_instead">Use API key instead?</string>
<string name="use_password_instead">Use password instead?</string>
<string name="bonded_title">Bonded Devices</string>
@@ -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,42 +13,74 @@ 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
private val _showLogs = MutableStateFlow(false)
val showLogs: StateFlow<Boolean> = _showLogs
/**
* Determines if the Debug Logs tab should be visible in the navigation bar.
*/
private val _showLogs = mutableStateOf(storage.load<Boolean>(ReferKeys.SHOW_LOGS) ?: false)
val showLogs: State<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)
}
fun setShowLogs(enabled: Boolean) {
_showLogs.value = enabled
/**
* Toggles between visible and hidden logs and persists the choice.
*/
fun toggleShowLogs(isVisible: Boolean) {
_showLogs.value = isVisible
storage.save(ReferKeys.SHOW_LOGS, isVisible)
}
}
@@ -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
}
/**
* 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,
@@ -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,
@@ -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,
@@ -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
@@ -8,12 +8,20 @@ import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager
import io.github.aakira.napier.Napier
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
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 +29,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 +45,29 @@ class AuthApi(
false
}
}
/**
* Attempts to authenticate the user using an API Key.
* Calls Endpoints.AUTH_REFRESH and stores the API Key as the access token.
*/
suspend fun loginWithApiKey(apiKey: String): Boolean {
return try {
val res: RefreshResponseDto = client.post("$baseUrl${Endpoints.AUTH_REFRESH}") {
contentType(ContentType.Application.Json)
setBody(RefreshRequestDto(apiKey))
}.body()
tokenManager.saveTokens(res.access, apiKey)
true
} catch (e: Exception) {
Napier.d(">>>>> [AuthApi.kt] loginWithApiKey Exception: $e")
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/"
@@ -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,13 +30,19 @@ 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("woID", woID)
parameter("wo_id", woID)
parameter("action", action)
}.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)
}
@@ -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,6 +48,7 @@ fun WorkOrderDto.toDomain(): WorkOrder {
updatedAt = updatedAt
)
}
fun MeasureResDto.toDomain(): MeasureResult {
return MeasureResult(
status = status,
@@ -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,22 @@
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"
const val SHOW_LOGS = "show_logs"
}
@@ -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("\\r\\n", "\r\n")
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,11 +3,22 @@ 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,
@@ -12,7 +12,6 @@ import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
@@ -53,7 +52,7 @@ fun MainScreen(
isLoggedIn: Boolean,
viewModel: AppViewModel = koinInject()
) {
val showLogs by viewModel.showLogs.collectAsState()
val showLogs by viewModel.showLogs
val destinations = buildList {
add(HomeDestination)
add(ScannerDestination)
@@ -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
@@ -39,7 +60,27 @@ class AuthViewModel(
if (success) {
_loginState.value = LoginUiState.Success
} else {
_loginState.value = LoginUiState.Error("Login failed: Invalid credentials")
_loginState.value = LoginUiState.Error("Incorrect username or password")
}
} catch (e: Exception) {
_loginState.value = LoginUiState.Error("Error: ${e.message}")
}
}
}
/**
* Attempts to log in the user using an API Key.
*/
fun loginWithApiKey(apiKey: String) {
viewModelScope.launch {
_loginState.value = LoginUiState.Loading
tokenManager.saveServerUrl(_serverUrl.value)
try {
val success = authApi.loginWithApiKey(apiKey)
if (success) {
_loginState.value = LoginUiState.Success
} else {
_loginState.value = LoginUiState.Error("Invalid API Key")
}
} catch (e: Exception) {
_loginState.value = LoginUiState.Error("Error: ${e.message}")
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -33,6 +34,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -53,7 +55,6 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeDestination
import kotlinx.coroutines.launch
@@ -61,13 +62,19 @@ import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.api_key
import torquevaultkmp.composeapp.generated.resources.btn_login
import torquevaultkmp.composeapp.generated.resources.dts_text
import torquevaultkmp.composeapp.generated.resources.lbl_password
import torquevaultkmp.composeapp.generated.resources.lbl_server_url
import torquevaultkmp.composeapp.generated.resources.lbl_username
import torquevaultkmp.composeapp.generated.resources.server_url_placeholder
import torquevaultkmp.composeapp.generated.resources.use_api_key_instead
import torquevaultkmp.composeapp.generated.resources.use_password_instead
/**
* Screen for user authentication.
*/
@Composable
fun LoginScreen(
navController: NavController
@@ -77,12 +84,15 @@ fun LoginScreen(
val serverUrl by viewModel.serverUrl.collectAsState()
LoginScreenContent(
viewModel = viewModel,
loginState = loginState,
serverUrl = serverUrl,
onServerUrlChange = { viewModel.onChangeServerUrl(it) },
onLogin = { username, password ->
viewModel.login(username, password)
},
onLoginWithApiKey = { apiKey ->
viewModel.loginWithApiKey(apiKey)
},
onLoginSuccess = {
navController.navigate(HomeDestination.route) {
popUpTo(AuthDestination.route) { inclusive = true }
@@ -92,19 +102,25 @@ fun LoginScreen(
)
}
/**
* Stateless UI content for the login screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreenContent(
viewModel: AuthViewModel,
loginState: LoginUiState,
serverUrl: String,
onServerUrlChange: (String) -> Unit,
onLogin: (String, String) -> Unit,
onLoginWithApiKey: (String) -> Unit,
onLoginSuccess: () -> Unit
) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
var username by remember { mutableStateOf("admin") }
var password by remember { mutableStateOf("admin") }
var apiKey by remember { mutableStateOf("") }
var useApiKey by remember { mutableStateOf(false) }
var passwordVisible by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
@@ -159,70 +175,107 @@ fun LoginScreenContent(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
value = serverUrl,
onValueChange = { viewModel.onChangeServerUrl(it) },
onValueChange = { onServerUrlChange(it) },
label = { Text(stringResource(Res.string.lbl_server_url)) },
placeholder = { Text(stringResource(Res.string.server_url_placeholder)) }
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
value = username,
onValueChange = { username = it },
label = { Text(stringResource(Res.string.lbl_username)) }
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val image =
if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = image,
contentDescription = if (passwordVisible) "Hide password" else "Show password"
)
}
},
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
if (username.isBlank() || password.isBlank()) {
if (!useApiKey) {
/* Use username and password */
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
value = username,
onValueChange = { username = it },
label = { Text(stringResource(Res.string.lbl_username)) }
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val image =
if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = image,
contentDescription = if (passwordVisible) "Hide password" else "Show password"
)
}
},
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
if (username.isBlank() || password.isBlank()) {
scope.launch {
snackbarHostState.showSnackbar("Username and Password is required!")
}
} else {
onLogin(username, password)
keyboardController?.hide()
focusManager.clearFocus()
}
}
),
value = password,
onValueChange = { password = it },
label = { Text(stringResource(Res.string.lbl_password)) }
)
} else {
/* Use api key */
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
if (apiKey.isBlank()) {
scope.launch {
snackbarHostState.showSnackbar("API Key is required!")
}
} else {
onLoginWithApiKey(apiKey)
keyboardController?.hide()
focusManager.clearFocus()
}
}
),
value = apiKey,
onValueChange = { apiKey = it },
label = { Text(stringResource(Res.string.api_key)) }
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
modifier = Modifier.fillMaxWidth().height(45.dp),
shape = RoundedCornerShape(8.dp),
onClick = {
if (useApiKey) {
if (apiKey.isBlank()) {
scope.launch {
snackbarHostState.showSnackbar("Username and Password is required!")
snackbarHostState.showSnackbar("API Key is required!")
}
} else {
onLogin(username, password)
onLoginWithApiKey(apiKey.trim())
keyboardController?.hide()
focusManager.clearFocus()
}
}
),
value = password,
onValueChange = { password = it },
label = { Text(stringResource(Res.string.lbl_password)) }
)
Spacer(modifier = Modifier.height(16.dp))
Button(
modifier = Modifier
.width(150.dp)
.height(45.dp),
onClick = {
if (username.isBlank() || password.isBlank()) {
scope.launch {
snackbarHostState.showSnackbar("Username and password is required!")
}
} else {
onLogin(username.trim(), password.trim())
keyboardController?.hide()
focusManager.clearFocus()
if (username.isBlank() || password.isBlank()) {
scope.launch {
snackbarHostState.showSnackbar("Username and password is required!")
}
} else {
onLogin(username.trim(), password.trim())
keyboardController?.hide()
focusManager.clearFocus()
}
}
},
enabled = loginState !is LoginUiState.Loading
@@ -237,6 +290,15 @@ fun LoginScreenContent(
)
}
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(
onClick = { useApiKey = !useApiKey },
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Text(stringResource(if (useApiKey) Res.string.use_password_instead else Res.string.use_api_key_instead))
}
}
}
}
@@ -245,10 +307,11 @@ fun LoginScreenContent(
@Composable
private fun LoginScreenPreview() {
LoginScreenContent(
viewModel = viewModel(),
loginState = LoginUiState.Idle,
serverUrl = "http://localhost:3000",
onServerUrlChange = {},
onLogin = {_, _ -> },
onLoginWithApiKey = {},
onLoginSuccess = {}
)
}
@@ -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(
@@ -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
@@ -171,11 +177,12 @@ class ScannerViewModel(
bleManager.scanDevices()
.catch {
Napier.d(">>>>> bleManager.scanDevices catch block")
_isScanning.value = false
stopCleanupJob()
}
.collect { advertisement ->
if (advertisement.name == null) return@collect
// if (advertisement.name == null) return@collect
val bleId: String = advertisement.identifier.toString()
if(_autoConnect.value && isBonded(bleId)){
connect(advertisement)
@@ -246,6 +253,9 @@ class ScannerViewModel(
val adv = bleManager.getAdvertisement(deviceId)
if (adv != null) {
connect(adv)
// Remove advertisement from list
advertisementMap.remove(deviceId)
updateDeviceList()
} else {
Napier.e("Cannot connect: Advertisement not found for $deviceId")
}
@@ -285,9 +295,10 @@ class ScannerViewModel(
fun sendCommand(peripheral: Peripheral, command: String) {
val deviceId = peripheral.identifier.toString()
viewModelScope.launch {
bleManager.sendCommand(peripheral, Helper.buildUartCommand(command))
appendHistory(deviceId, "Sent: $command")
logRepository.appendLog(title = "Communicate - Sent", content = command, deviceId)
val data = Helper.buildUartCommand(command)
bleManager.sendCommand(peripheral, data)
appendHistory(deviceId, "Sent: $data")
logRepository.appendLog(title = "Communicate - Sent", content = data, deviceId)
}
}
@@ -361,111 +372,125 @@ class ScannerViewModel(
_connectedDevices.value -= deviceIdentifier
}
}
fun fetchWorkOrders(peripheral: Peripheral, woID: String = "0", action: String = "next") {
private fun fetchWorkOrders(peripheral: Peripheral, command: Helper.Command, woID: String = "0", action: String = "next") {
viewModelScope.launch {
try {
val orders = scannerRepository.getAbleWorkOrders(woID = woID, action = action)
_workOrders.value = orders
sendWorkOrdersToDevice(peripheral,orders)
sendWorkOrdersToDevice(peripheral, command.name, orders)
} catch (e: Exception) {
Napier.e("Failed to load work orders", e)
logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
}
}
}
private fun sendWorkOrdersToDevice(peripheral: Peripheral, workOrders: List<WorkOrder>) {
private fun sendWorkOrdersToDevice(peripheral: Peripheral, command: String, workOrders: List<WorkOrder>) {
viewModelScope.launch {
if (workOrders.isEmpty()) {
sendCommand(peripheral, Helper.CMD_EMPTY_WO)
sendCommand(peripheral, Helper.CMD_EMPTY_WO(command))
return@launch
}
val formatted = workOrders.joinToString(separator = "\r\n") { wo ->
val formatted = workOrders.joinToString(separator = "\n") { wo ->
val torque = wo.services?.values?.firstOrNull()?.torqueRequired ?: 0.0
val actionCode = if (wo.status.lowercase() == "open") 0 else 1
val wheelsMask = wo.computeWheelsNuts()
"$${wo.id},${actionCode},${wo.make},${wo.licensePlate},$torque,ft-lb,$wheelsMask*"
"${Helper.WO_RES};${wo.id};${actionCode};${wo.make};${wo.licensePlate};$torque;ft-lb;$wheelsMask"
}
sendCommand(peripheral, formatted)
}
}
private fun acceptWorkOrder(peripheral: Peripheral, type: Int, woId: String, deviceIdentifier: String) {
viewModelScope.launch {
var command = Helper.CMD_RES_NG(Helper.Command.ACCEPT_WO.name)
try {
val res = scannerRepository.confirmWorkOrder(woId, deviceIdentifier, type)
if (res)
command = Helper.CMD_RES_OK(Helper.Command.ACCEPT_WO.name)
sendCommand(peripheral, command)
} catch (e: Exception) {
Napier.e(">>>>> Failed to handle message data", e)
sendCommand(peripheral, command)
logRepository.appendLog(
title = "HTTP",
content = "${e.message}".substringBefore("[url=")
)
}
}
}
private fun finishWorkOrder(peripheral: Peripheral, type: Int, woId: String, data: String, deviceIdentifier: String) {
val bleData = data.trim(' ').split(";")
val nuts: Int = bleData[Helper.BLE_NUTS_POS].toInt()
val torqueData: List<String> = bleData.subList(Helper.BLE_TORQUE_POS, bleData.size)
val wheelTorqueData = Helper.parseWheelTorqueData(torqueData, nuts)
val dto = SendMeasureDto(
deviceId = deviceIdentifier,
type = type,
nuts = nuts,
torqueData = wheelTorqueData
)
Napier.d(">>>>> DTO $dto")
viewModelScope.launch {
var command = Helper.CMD_RES_NG(Helper.Command.FINISH_WO.name)
when (val act = bleData.firstOrNull()) {
Helper.TorqueAction.TORQUE -> {
val res = scannerRepository.sendMeasurementResult(woId, dto)
if (res.isSuccess) {
command = Helper.CMD_RES_OK(Helper.Command.FINISH_WO.name)
} else {
val e = res.exceptionOrNull()
Napier.e("An error occurred while sending the measurement", e, tag = "ScannerViewModel")
logRepository.appendLog(title = "HTTP", content = "${e?.message}".substringBefore("[url="))
}
sendCommand(peripheral, command)
}
Helper.TorqueAction.RE_TORQUE -> {
val res = scannerRepository.sendMeasurementResult(woId, dto, true)
if (res.isSuccess) {
command = Helper.CMD_RES_OK(Helper.Command.FINISH_WO.name)
} else {
val e = res.exceptionOrNull()
Napier.e("An error occurred while sending the re-measurement", e, tag = "ScannerViewModel")
logRepository.appendLog(title = "HTTP", content = "${e?.message}".substringBefore("[url="))
}
sendCommand(peripheral, command)
}
Helper.TorqueAction.CANCEL -> {
val res = scannerRepository.sendCancelWorkOrder(woId, dto)
if (res.isSuccess) {
command = Helper.CMD_RES_OK(Helper.Command.FINISH_WO.name)
} else {
val e = res.exceptionOrNull()
Napier.e("An error occurred while cancel work order", e, tag = "ScannerViewModel")
logRepository.appendLog(title = "HTTP", content = "${e?.message}".substringBefore("[url="))
}
sendCommand(peripheral, command)
}
else -> Napier.w(">>>>> Unknown action: $act")
}
}
}
private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) {
appendHistory(deviceIdentifier, "Received: $msg")
logRepository.appendLog(title = "Communicate - Received", content = msg, deviceId = deviceIdentifier)
val decoded = Helper.decodeRxData(msg) ?: return
val data = decoded.data
val woId = decoded.id
val cmd = data.take(4)
val action = if (cmd == "back") "back" else "next"
viewModelScope.launch {
Napier.d(">>>>> decoded rx message: $decoded")
val cmd = Helper.Command.fromString(decoded.command)
val type = Helper.TypeOfDevice.fromString(decoded.type)
if(type != null) {
val woId = decoded.woId
val data = decoded.data
when (cmd) {
"requ", "next", "back" -> fetchWorkOrders(peripheral, woId, action)
"conf" -> {
try {
val res = scannerRepository.confirmWorkOrder(woId, deviceIdentifier, 0)
var command = Helper.CMD_NOT_AVAILABLE_WO
if (res)
command = Helper.CMD_RECEIVED_WO
sendCommand(peripheral, command)
} catch (e: Exception) {
Napier.e(">>>>> Failed to handle message: $msg", e)
sendCommand(peripheral, Helper.CMD_NOT_AVAILABLE_WO)
logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
}
}
else -> {
/** Send torque, re-torque or cancel case */
val bleData = data.trim(' ').trimEnd('*').split(",")
val nuts: Int = bleData[Helper.BLE_NUTS_POS].toInt()
val torqueData: List<String> = bleData.subList(Helper.BLE_TORQUE_POS, bleData.size)
val wheelTorqueData = Helper.parseWheelTorqueData(torqueData, nuts)
val dto = SendMeasureDto(
deviceId = deviceIdentifier,
type = 0, // tbu
nuts = nuts,
torqueData = wheelTorqueData
)
when (val act = bleData.firstOrNull()) {
Helper.TorqueAction.TORQUE -> {
try {
scannerRepository.sendMeasurementResult(woId, dto)
} catch (e: Exception) {
Napier.e("An error occurred while sending the measurement", e, tag = "ScannerViewModel")
logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally {
// Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
}
}
Helper.TorqueAction.RE_TORQUE -> {
try {
scannerRepository.sendMeasurementResult(woId, dto, true)
} catch (e: Exception) {
Napier.e("An error occurred while sending the re-measurement", e, tag = "ScannerViewModel")
logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally {
// Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
}
}
Helper.TorqueAction.CANCEL -> {
try {
scannerRepository.sendCancelWorkOrder(woId, dto)
} catch (e: Exception) {
Napier.e("An error occurred while cancel work order", e, tag = "ScannerViewModel")
logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally {
// Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
}
}
else -> Napier.w(">>>>> Unknown action: $act")
}
Helper.Command.START_WO -> fetchWorkOrders(peripheral, Helper.Command.START_WO)
Helper.Command.NEXT_WO -> fetchWorkOrders(peripheral, Helper.Command.NEXT_WO, woId, "next")
Helper.Command.PREVIOUS_WO -> fetchWorkOrders(peripheral, Helper.Command.PREVIOUS_WO, woId, "back")
Helper.Command.ACCEPT_WO -> acceptWorkOrder(peripheral, type.code, woId, deviceIdentifier)
Helper.Command.FINISH_WO -> {
finishWorkOrder(peripheral, type.code, woId, data, deviceIdentifier)
}
null -> Napier.w("Unknown command: $cmd")
}
}
} else Napier.w("Unknown type: $type")
}
fun addBondedDevice(id: String, name: String) {
viewModelScope.launch {
deviceRepository.saveDevice(id, name)
@@ -69,13 +69,13 @@ fun SettingScreen(
val serverUrl by viewModel.serverUrl.collectAsState()
val token by viewModel.refreshToken.collectAsState()
val autoConnect by viewModel.autoConnect.collectAsState()
val showLogs by appViewModel.showLogs.collectAsState()
val showLogs by appViewModel.showLogs
SettingContent(
isDarkTheme = isDarkTheme,
onThemeToggle = onThemeToggle,
isShowLogs = showLogs,
onToggleLogs = { appViewModel.setShowLogs(it) },
onToggleLogs = { appViewModel.toggleShowLogs(it) },
serverUrl = serverUrl,
onServerUrlChange = { viewModel.updateServerUrl(it) },
token = token,
@@ -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,39 +1,118 @@
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 CMD_EMPTY_WO = $$"$e*\r\n"
const val CMD_RECEIVED_WO = $$"$r*\r\n"
const val CMD_NOT_AVAILABLE_WO = $$"$n*\r\n"
const val CMD_UPLOADED_WO = $$"$u*\r\n"
const val END_LINE_FEED = "#\r\n"
const val START_CHARACTER ="$"
const val END_LINE_FEED = "#\n"
const val SECURITY_CRC16 = "00cr16"
const val BLE_NUTS_POS = 4
const val WO_RES = "WO_RES"
/** Generates empty response for a specific command. */
val CMD_EMPTY_WO: (String) -> String = {cmd -> "$cmd;empty"}
/** 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,
PREVIOUS_WO,
ACCEPT_WO,
FINISH_WO;
companion object {
fun fromString(code: String): Command? =
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),
DRAIN_PLUG(2);
companion object {
fun fromString(type: String): TypeOfDevice? = when (type) {
"wheel" -> WHEEL
"oil_filter" -> OIL_FILTER
"drain_plug" -> DRAIN_PLUG
else -> null
}
}
}
/** 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 id: String,
val data: String
val command: String,
val type: String, // Type of device
val woId: String = "0",
val data: String,
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 "$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? {
if (!msg.startsWith("$")) return null
val payload = msg.drop(1).trimEnd('*', ' ', '\r', '\n')
val parts = payload.split(",", limit = 2)
if (parts.size < 2) return null
val woID = parts[0]
val data = parts[1]
return RxMessage(woID, data)
/** Check header and footer*/
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)
if(parts.size < 2) return null
val cmd = parts.first()
var woId = "0"
var data = ""
val type = parts[1].substringBefore("\n")
/** Tail include security code and crc16 */
val tail = parts.last().substringAfterLast("\n")
val security = tail.take(2)
val crc16 = tail.drop(2).take(4)
if(parts.size == 3) {
val payloadParts = parts.last().substringBeforeLast("\n").split(";")
woId = payloadParts.firstOrNull() ?: "0"
data = payloadParts.drop(1).joinToString(";") // exclude woId
}
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
@@ -52,6 +131,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,6 +3,10 @@ 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)
}
@@ -0,0 +1,159 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class UartCommunicationTest {
/**
* Tests the "Start Work Order" communication flow.
* 1. Device sends a request for work orders ($START_WO;wheel)
* 2. App responds with work order data or empty notification.
*/
@Test
fun testStartWorkOrderFlow() {
// --- STEP 1: App receives request from Device ---
// Format: $COMMAND;TYPE;[WOID;DATA...]\nSECURITYCRC#\n
val deviceRequest = $$"$START_WO;wheel\n00cr16#\n"
val decoded = Helper.decodeRxData(deviceRequest)
assertNotNull(decoded)
assertEquals(Helper.Command.START_WO.name, decoded.command)
assertEquals("wheel", decoded.type)
assertEquals("0", decoded.woId)
assertEquals("", decoded.data)
assertEquals("00", decoded.securityCode)
assertEquals("cr16", decoded.crc16)
// --- STEP 2: App prepares response (Empty Case) ---
val emptyCase = Helper.buildUartCommand(Helper.CMD_EMPTY_WO("START_WO"))
assertEquals($$"$START_WO;empty\n00cr16#\n", emptyCase)
// --- STEP 3: App prepares response (Multiple WO Case) ---
// Format: WO_RES;id;action;make;license;torque;unit;wheelsMask
val wo1 = "WO_RES;1024;0;Mercedes-Benz;A-910;90.00;Nm;3076"
val wo2 = "WO_RES;1025;0;Audi;B-123;100.00;ft-lb;773"
val combined = "$wo1\n$wo2"
val finalCommand = Helper.buildUartCommand(combined)
val expected = $$"$WO_RES;1024;0;Mercedes-Benz;A-910;90.00;Nm;3076\n" +
"WO_RES;1025;0;Audi;B-123;100.00;ft-lb;773\n" +
"00cr16#\n"
assertEquals(expected, finalCommand)
}
/**
* Tests the "Confirm Work Order" communication flow.
* 1. Device sends a request to accept a specific work order ($ACCEPT_WO;wheel;1024)
* 2. App responds with OK or NG confirmation.
*/
@Test
fun testConfirmWorkOrder() {
// --- STEP 1: App receives confirmation from Device ---
val deviceRequest = $$"$ACCEPT_WO;wheel;1024\n00cr16#\n"
val decoded = Helper.decodeRxData(deviceRequest)
assertNotNull(decoded)
assertEquals(Helper.Command.ACCEPT_WO.name, decoded.command)
assertEquals("wheel", decoded.type)
assertEquals("1024", decoded.woId)
assertEquals("00", decoded.securityCode)
assertEquals("cr16", decoded.crc16)
// --- STEP 2: App prepares OK response ---
val okResponseStr = Helper.CMD_RES_OK(Helper.Command.ACCEPT_WO.name)
val finalOkCommand = Helper.buildUartCommand(okResponseStr)
assertEquals($$"$ACCEPT_WO;OK\n00cr16#\n", finalOkCommand)
// --- Exception: App prepares NG response ---
val ngResponseStr = Helper.CMD_RES_NG(Helper.Command.ACCEPT_WO.name)
val finalNgCommand = Helper.buildUartCommand(ngResponseStr)
assertEquals($$"$ACCEPT_WO;NG\n00cr16#\n", finalNgCommand)
}
/**
* Tests the "Finish Work Order" data parsing flow.
* Simulates receiving complex torque measurement data from the device.
*/
@Test
fun testFinishWorkOrderFlow() {
// Device sends finish data: $FINISH_WO;w_type;woId;action;unit;nuts;[wheelName;torqueValue...]
// Action: 0 (Torque), 1 (Re-torque), 2 (Cancel)
// In this example: Action=0, Unit=ft-lb, Nuts=7, Wheel=DF (70.70, 70.77, 69.69, 77.89, 71.69, 70.00, 70.00)
val rawData = $$"$FINISH_WO;wheel;1024;0;ft-lb;7;DF;70.70;70.77;69.69;77.89;71.69;70.00;70.00\n00cr16#\n"
val decoded = Helper.decodeRxData(rawData)
assertNotNull(decoded)
assertEquals("FINISH_WO", decoded.command)
assertEquals("wheel", decoded.type)
assertEquals("1024", decoded.woId)
assertEquals("00", decoded.securityCode)
assertEquals("cr16", decoded.crc16)
assertEquals("0;ft-lb;7;DF;70.70;70.77;69.69;77.89;71.69;70.00;70.00", decoded.data)
val bleData = decoded.data.split(";")
val action = bleData[0]
val nuts = bleData[2].toInt()
val torqueDataStrings = bleData.subList(3, bleData.size)
assertEquals(Helper.TorqueAction.TORQUE, action)
assertEquals(7, nuts)
// Final parsing into a map using Helper
val wheelTorqueData = Helper.parseWheelTorqueData(torqueDataStrings, nuts)
assertEquals(1, wheelTorqueData.size)
assertEquals(listOf(70.70, 70.77, 69.69, 77.89, 71.69, 70.00, 70.00), wheelTorqueData["DF"])
// --- App prepares OK response ---
val okResponseStr = Helper.CMD_RES_OK(Helper.Command.FINISH_WO.name)
val finalOkCommand = Helper.buildUartCommand(okResponseStr)
assertEquals($$"$FINISH_WO;OK\n00cr16#\n", finalOkCommand)
// --- Exception: App prepares NG response ---
val ngResponseStr = Helper.CMD_RES_NG(Helper.Command.FINISH_WO.name)
val finalNgCommand = Helper.buildUartCommand(ngResponseStr)
assertEquals($$"$FINISH_WO;NG\n00cr16#\n", finalNgCommand)
}
/**
* Tests the "Cancel Work Order" communication flow.
* 1. Device sends a request to cancel the current work order ($FINISH_WO;wheel;1024;2;...)
* 2. App responds with OK confirmation.
*/
@Test
fun testCancelWorkOrder() {
// --- STEP 1: App receives cancel request from Device ---
// Action "2" represents CANCEL in TorqueAction
val rawData = $$"$FINISH_WO;wheel;1024;2;ft-lb;7;DF;70.70;70.77;69.69;77.89;71.69;70.00;70.00\n00cr16#\n"
val decoded = Helper.decodeRxData(rawData)
assertNotNull(decoded)
assertEquals("FINISH_WO", decoded.command)
assertEquals("wheel", decoded.type)
assertEquals("1024", decoded.woId)
assertEquals("00", decoded.securityCode)
assertEquals("cr16", decoded.crc16)
assertEquals("2;ft-lb;7;DF;70.70;70.77;69.69;77.89;71.69;70.00;70.00", decoded.data)
val bleData = decoded.data.split(";")
val action = bleData[0]
assertEquals(Helper.TorqueAction.CANCEL, action)
// --- App prepares OK response ---
val okResponseStr = Helper.CMD_RES_OK(Helper.Command.FINISH_WO.name)
val finalOkCommand = Helper.buildUartCommand(okResponseStr)
assertEquals($$"$FINISH_WO;OK\n00cr16#\n", finalOkCommand)
// --- Exception: App prepares NG response ---
val ngResponseStr = Helper.CMD_RES_NG(Helper.Command.FINISH_WO.name)
val finalNgCommand = Helper.buildUartCommand(ngResponseStr)
assertEquals($$"$FINISH_WO;NG\n00cr16#\n", finalNgCommand)
}
}
@@ -2,6 +2,7 @@ package com.digitoolsolutions.app.torquevaultkmp
import androidx.compose.ui.window.ComposeUIViewController
import com.digitoolsolutions.app.torquevaultkmp.di.initKoin
import com.juul.kable.CentralManager
import io.github.aakira.napier.DebugAntilog
import io.github.aakira.napier.Napier
@@ -9,6 +10,9 @@ fun MainViewController() = ComposeUIViewController(
configure = {
// Add logger lib
Napier.base(DebugAntilog()) // NoOpAntilog() for release
CentralManager.configure {
stateRestoration = true
}
initKoin()
}
) { App() }
@@ -1,14 +1,12 @@
package com.digitoolsolutions.app.torquevaultkmp
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi
import com.digitoolsolutions.app.torquevaultkmp.data.storage.ReferKeys
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.Endpoints
import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager
import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException
import io.github.aakira.napier.Napier
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
@@ -17,11 +15,7 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.plugin
import io.ktor.client.request.header
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
actual fun createPlatformHttpClient(
@@ -45,6 +39,9 @@ actual fun createPlatformHttpClient(
} else null
}
refreshTokens {
if (response.call.request.url.encodedPath.contains(Endpoints.AUTH_TOKEN)) {
return@refreshTokens null
}
Napier.d(">>>>> [NetworkEngine.ios.kt] Access token expired, auto request refresh token...")
val newAccess = authApi?.refreshToken()
if (newAccess != null) {
@@ -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()
+83
View File
@@ -0,0 +1,83 @@
# TorqueVault User Guide
TorqueVault is an application used to communicate with digital torque wrenches via Bluetooth Low Energy (BLE). The application communicates with a backend server via a RESTful API to get data and upload measurement values obtained from the wrenches.
<div align="center">
<img src="../composeApp/src/commonMain/composeResources/drawable/dts_text.png" alt="DTS Logo" width="200" style="background-color: white; padding: 10px; border-radius: 8px;"/>
</div>
---
## 1. Login Screen
Authentication interface for securing access to the application.
* **Server Configuration:** Entry of the backend API server URL for synchronization.
* **Authentication Methods:**
* **Credentials:** Login using a registered username and password.
* **API Key:** Alternative login method using a unique API token.
<div align="center">
<img src="./screenshots/Screenshot_Login_With_Password.png" alt="Login Screen" width="250"/>
</div>
## 2. Scanner Screen
Facilitates the discovery of nearby tools.
* **Search:** Automatic scanning for active Bluetooth torque devices.
* **Signal Strength:** Proximity indication via the signal icon (RSSI).
* **Connect:** Tap a device name to initiate a connection. The device is automatically bonded upon the first successful connection.
<div align="center">
<img src="./screenshots/Screenshot_Scanner.png" alt="Scanner Screen" width="250"/>
</div>
## 3. Home Screen
Primary dashboard for active tool management.
* **Overview:** Display of all currently connected devices.
* **Quick Access:** One-tap entry to the communication screen of any connected tool.
<div align="center">
<img src="./screenshots/Screenshot_Home.png" alt="Home Screen" width="250"/>
</div>
## 4. Communication Screen
Direct interaction interface for selected tools.
* **UART Data Exchange:** Display of UART commands exchanged between the torque wrench and the application.
* **Command Transmission:** Ability to send specific instructions or protocol commands.
<div align="center">
<img src="./screenshots/Screenshot_Communication.png" alt="Communication Screen" width="250"/>
</div>
## 5. Bonded Devices Screen
Management of previously paired tools.
* **Bonded devices:** List of all previously paired devices.
* **Device management:** Option to remove obsolete tool records via the delete icon.
* **Auto-Connect Capability:** Automatic reconnection for bonded tools within range if the "Auto-Connect" option is enabled in the Settings screen.
<div align="center">
<img src="./screenshots/Screenshot_Bonded_Devices.png" alt="Bonded Devices" width="250"/>
</div>
## 6. Logs Screen
Tracking of system activity and communication logs for the current session. This screen is hidden by default and can be enabled via the "Show logs" option in the Settings screen.
* **Session Logs:** Detailed log of application activities and data exchanges.
* **Filter Options:** Categorization between system messages and tool data.
* **Data Export:** Capability to share logs in Text or CSV format for diagnostic purposes.
<div align="center">
<img src="./screenshots/Screenshot_Logs.png" alt="Logs Screen" width="250"/>
</div>
## 7. Settings Screen
Application-wide configuration.
* **Theme Selection:** Toggle between Light and Dark visual modes.
* **Connection Policy:** Management of the auto-connect feature for bonded tools.
* **Server:** Configuration of the backend server URL to get work orders and upload measurement data.
<div align="center">
<img src="./screenshots/Screenshot_Settings.png" alt="Settings Screen" width="250"/>
</div>
---
*Developed by Digitool Solutions*
*Last updated: Aug 19, 2026*
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+4 -1
View File
@@ -23,7 +23,10 @@
<string>The app requires Bluetooth to connect and manage Torque devices.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>The app requires Bluetooth to connect and manage Torque devices.</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
<key>UILaunchScreen</key>
<dict/>
</dict>