Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
399113e3f1 | ||
|
|
1f5c5d7557 | ||
|
|
3fb07872c2 | ||
|
|
f2ba761cc1 | ||
|
|
e94c3f5c61 | ||
|
|
67ef6c8aa7 | ||
|
|
2b4e4f86b2 | ||
|
|
0ee4e742c9 | ||
|
|
c8aac2f6db | ||
|
|
c6cb6b0bcc | ||
|
|
c75d8de4b4 | ||
|
|
557681cbeb | ||
|
|
8af98f505f |
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
flows:
|
||||
- flows/*
|
||||
|
||||
executionOrder:
|
||||
continueOnFailure: false
|
||||
flowsOrder:
|
||||
- login
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
appId: com.digitoolsolutions.app.torquevaultkmp
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/launch-app.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/login-steps.yaml
|
||||
@@ -0,0 +1,6 @@
|
||||
appId: com.digitoolsolutions.app.torquevaultkmp
|
||||
---
|
||||
#- runFlow:
|
||||
# file: ../subflows/launch-app.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/communicate-steps.yaml
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,6 @@
|
||||
appId: com.digitoolsolutions.app.torquevaultkmp
|
||||
---
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
@@ -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"
|
||||
@@ -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 that’s 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 Apple’s 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 you’re 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 IDE’s 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 IDE’s 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**.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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*
|
||||
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -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>
|
||||
|
||||