Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 :androidApp:assembleDebug --stacktrace
|
||||||
|
|
||||||
|
- name: Find location APK file
|
||||||
|
run: |
|
||||||
|
find . -name "*.apk"
|
||||||
|
|
||||||
|
- name: Show APK
|
||||||
|
run: |
|
||||||
|
ls -lah androidApp/build/outputs/apk/debug/
|
||||||
|
|
||||||
|
# - uses: actions/upload-artifact@v4
|
||||||
|
# with:
|
||||||
|
# name: TorqueVault-apk
|
||||||
|
# path: androidApp/build/outputs/apk/debug/androidApp-debug.apk
|
||||||
|
|
||||||
|
- name: Build Package Version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION_NAME=$(grep -R "versionName" androidApp/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 androidApp/build/outputs/apk/debug/androidApp-debug.apk \
|
||||||
|
"${{ github.server_url }}/api/packages/${{ github.repository_owner }}/generic/torque-vault/${PACKAGE_VERSION}/Torque-Vault.apk"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: Sync Source to Host
|
||||||
|
run-name: ${{ gitea.actor }} is synchronizing the source code to the host
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
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.
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
* [/iosApp](./iosApp/iosApp) contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform,
|
The project targets **Android** and **iOS** using **Compose Multiplatform** for a shared UI and logic.
|
||||||
you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
|
|
||||||
|
|
||||||
### 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
|
- **BLE Device Management**: Scan, connect, and bond with digital torque wrenches using the [Kable](https://github.com/JuulLabs/kable) library.
|
||||||
in your IDE’s toolbar or build it directly from the terminal:
|
- **Adaptive Scanning**: Automatically calibrates device discovery expiration by observing advertisement intervals, ensuring a responsive UI when devices stop broadcasting.
|
||||||
- on macOS/Linux
|
- **Work Order Workflow**:
|
||||||
```shell
|
- Fetch "Open" work orders from a remote API.
|
||||||
./gradlew :composeApp:assembleDebug
|
- 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.
|
||||||
- on Windows
|
- **Bonding & Persistence**: Automatically remembers and auto-connects to bonded devices using **SQLDelight** for local storage.
|
||||||
```shell
|
- **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.
|
||||||
.\gradlew.bat :composeApp:assembleDebug
|
- **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
|
- **Kotlin**: `2.3.21`
|
||||||
in your IDE’s toolbar or open the [/iosApp](./iosApp) directory in Xcode and run it from there.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
Developed by **Digitool Solutions**.
|
||||||
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)…
|
|
||||||
|
|||||||
@@ -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"
|
baseName = "ComposeApp"
|
||||||
isStatic = false
|
isStatic = false
|
||||||
linkerOpts("-lsqlite3")
|
linkerOpts("-lsqlite3")
|
||||||
|
freeCompilerArgs += listOf("-Xbinary=bundleId=com.digitoolsolutions.app.composeapp")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ android {
|
|||||||
applicationId = "com.digitoolsolutions.app.torquevaultkmp"
|
applicationId = "com.digitoolsolutions.app.torquevaultkmp"
|
||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = 2
|
versionCode = 4
|
||||||
versionName = "1.0.2"
|
versionName = "1.0.2"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
package com.digitoolsolutions.app.torquevaultkmp
|
package com.digitoolsolutions.app.torquevaultkmp
|
||||||
|
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi
|
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.data.storage.TokenManager
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException
|
import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException
|
||||||
import io.github.aakira.napier.Napier
|
import io.github.aakira.napier.Napier
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.okhttp.OkHttp
|
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.HttpTimeout
|
||||||
import io.ktor.client.plugins.auth.Auth
|
import io.ktor.client.plugins.auth.Auth
|
||||||
import io.ktor.client.plugins.auth.providers.BearerTokens
|
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.LogLevel
|
||||||
import io.ktor.client.plugins.logging.Logger
|
import io.ktor.client.plugins.logging.Logger
|
||||||
import io.ktor.client.plugins.logging.Logging
|
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 io.ktor.serialization.kotlinx.json.json
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
@@ -45,6 +40,9 @@ actual fun createPlatformHttpClient(
|
|||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
refreshTokens {
|
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...")
|
Napier.d(">>>>> [NetworkEngine.android.kt] Access token expired, auto request refresh token...")
|
||||||
val newAccess = authApi?.refreshToken()
|
val newAccess = authApi?.refreshToken()
|
||||||
if (newAccess != null) {
|
if (newAccess != null) {
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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 {
|
class AndroidBluetoothManager(private val context: Context) : BluetoothManager {
|
||||||
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as AndroidBluetoothManager
|
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as AndroidBluetoothManager
|
||||||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
||||||
|
|||||||
@@ -19,11 +19,15 @@
|
|||||||
<string name="dark_theme">Dark theme</string>
|
<string name="dark_theme">Dark theme</string>
|
||||||
<string name="auto_connect">Auto-connect device</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="server_url_placeholder">Ex: https://example.com</string>
|
||||||
<string name="lbl_username">Username</string>
|
<string name="lbl_username">Username</string>
|
||||||
<string name="lbl_password">Password</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>
|
<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 com.digitoolsolutions.app.torquevaultkmp.theme.TorqueVaultTheme
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
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
|
@Composable
|
||||||
@Preview
|
@Preview
|
||||||
fun App(
|
fun App(
|
||||||
|
|||||||
@@ -13,42 +13,74 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
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(
|
class AppViewModel(
|
||||||
private val tokenManager: TokenManager,
|
private val tokenManager: TokenManager,
|
||||||
private val storage: AppStorage
|
private val storage: AppStorage
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable state for the current theme.
|
||||||
|
* If null, the system theme is used.
|
||||||
|
*/
|
||||||
private val _isDarkTheme = mutableStateOf<Boolean?>(storage.load(ReferKeys.THEME))
|
private val _isDarkTheme = mutableStateOf<Boolean?>(storage.load(ReferKeys.THEME))
|
||||||
val isDarkTheme: State<Boolean?> = _isDarkTheme
|
val isDarkTheme: State<Boolean?> = _isDarkTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies the UI when the authentication token has expired.
|
||||||
|
*/
|
||||||
private val _sessionExpired = mutableStateOf(false)
|
private val _sessionExpired = mutableStateOf(false)
|
||||||
val sessionExpired: State<Boolean> = _sessionExpired
|
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 {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
// Observe the token manager for session expiration events
|
||||||
tokenManager.sessionExpired.collectLatest {
|
tokenManager.sessionExpired.collectLatest {
|
||||||
_sessionExpired.value = true
|
_sessionExpired.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the session expiration alert.
|
||||||
|
*/
|
||||||
fun dismissSessionExpired() {
|
fun dismissSessionExpired() {
|
||||||
_sessionExpired.value = false
|
_sessionExpired.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a valid session exists.
|
||||||
|
*/
|
||||||
fun isLoggedIn(): Boolean {
|
fun isLoggedIn(): Boolean {
|
||||||
return tokenManager.getRefreshToken() != null
|
return tokenManager.getRefreshToken() != null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles between Light and Dark mode and persists the choice.
|
||||||
|
*/
|
||||||
fun toggleTheme(isDark: Boolean) {
|
fun toggleTheme(isDark: Boolean) {
|
||||||
_isDarkTheme.value = isDark
|
_isDarkTheme.value = isDark
|
||||||
storage.save(ReferKeys.THEME, 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 com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager
|
||||||
import io.ktor.client.HttpClient
|
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
|
expect fun createPlatformHttpClient(tokenManager: TokenManager, authApi: AuthApi?): HttpClient
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
package com.digitoolsolutions.app.torquevaultkmp
|
package com.digitoolsolutions.app.torquevaultkmp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface representing the current operating platform (Android or iOS).
|
||||||
|
*/
|
||||||
interface Platform {
|
interface Platform {
|
||||||
|
/** The name of the platform (e.g., "Android 34", "iOS 17.2"). */
|
||||||
val name: String
|
val name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
expect fun getPlatform(): Platform
|
/**
|
||||||
|
* Returns the [Platform] implementation for the current target.
|
||||||
|
*/
|
||||||
|
expect fun getPlatform(): Platform
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ import torquevaultkmp.composeapp.generated.resources.baseline_menu_24
|
|||||||
import torquevaultkmp.composeapp.generated.resources.menu_item_accessibility
|
import torquevaultkmp.composeapp.generated.resources.menu_item_accessibility
|
||||||
import torquevaultkmp.composeapp.generated.resources.navigation_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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AppBar(
|
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
|
||||||
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
|
@Composable
|
||||||
internal fun BigIcon(
|
internal fun BigIcon(
|
||||||
imageVector: ImageVector,
|
imageVector: ImageVector,
|
||||||
@@ -27,6 +30,9 @@ internal fun BigIcon(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A version of [BigIcon] that uses a [Painter] instead of an [ImageVector].
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BigIcon(
|
internal fun BigIcon(
|
||||||
painterResource: Painter,
|
painterResource: Painter,
|
||||||
@@ -40,4 +46,4 @@ internal fun BigIcon(
|
|||||||
modifier = modifier.size(size),
|
modifier = modifier.size(size),
|
||||||
colorFilter = ColorFilter.tint(color),
|
colorFilter = ColorFilter.tint(color),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import torquevaultkmp.composeapp.generated.resources.Res
|
import torquevaultkmp.composeapp.generated.resources.Res
|
||||||
import torquevaultkmp.composeapp.generated.resources.baseline_close_24
|
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
|
@Composable
|
||||||
fun FilterButton(
|
fun FilterButton(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -34,6 +38,9 @@ fun FilterButton(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A version of [FilterButton] that takes a [Painter].
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun FilterButton(
|
fun FilterButton(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -60,6 +67,9 @@ fun FilterButton(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A version of [FilterButton] that takes an [ImageVector].
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun FilterButton(
|
fun FilterButton(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -77,4 +87,4 @@ fun FilterButton(
|
|||||||
containerColorDisabled = containerColorDisabled,
|
containerColorDisabled = containerColorDisabled,
|
||||||
onClick = onClick
|
onClick = onClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import androidx.compose.ui.text.AnnotatedString
|
|||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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
|
@Composable
|
||||||
internal fun Hint(
|
internal fun Hint(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -28,6 +31,9 @@ internal fun Hint(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An [AnnotatedString] version of the [Hint] component.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun Hint(
|
internal fun Hint(
|
||||||
text: AnnotatedString,
|
text: AnnotatedString,
|
||||||
@@ -43,4 +49,4 @@ internal fun Hint(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
textAlign = textAlign
|
textAlign = textAlign
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ fun RssiIcon(rssi: Int) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Determines the appropriate signal strength icon resource based on the RSSI value.
|
||||||
|
*/
|
||||||
private fun getImageRes(rssi: Int): DrawableResource = when {
|
private fun getImageRes(rssi: Int): DrawableResource = when {
|
||||||
rssi < MEDIUM_RSSI -> Res.drawable.ic_signal_min
|
rssi < MEDIUM_RSSI -> Res.drawable.ic_signal_min
|
||||||
rssi < MAX_RSSI -> Res.drawable.ic_signal_medium
|
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.github.aakira.napier.Napier
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
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.post
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API service for authentication-related operations.
|
||||||
|
*
|
||||||
|
* Handles user login and authentication token refreshing.
|
||||||
|
*/
|
||||||
class AuthApi(
|
class AuthApi(
|
||||||
private val client: HttpClient,
|
private val client: HttpClient,
|
||||||
private val tokenManager: TokenManager
|
private val tokenManager: TokenManager
|
||||||
@@ -21,6 +29,9 @@ class AuthApi(
|
|||||||
private val baseUrl: String?
|
private val baseUrl: String?
|
||||||
get() = tokenManager.getServerUrl()
|
get() = tokenManager.getServerUrl()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to authenticate the user and stores the received tokens.
|
||||||
|
*/
|
||||||
suspend fun login(username: String, password: String): Boolean {
|
suspend fun login(username: String, password: String): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val res: LoginResponseDto = client.post("$baseUrl${Endpoints.AUTH_TOKEN}") {
|
val res: LoginResponseDto = client.post("$baseUrl${Endpoints.AUTH_TOKEN}") {
|
||||||
@@ -34,6 +45,29 @@ class AuthApi(
|
|||||||
false
|
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? {
|
suspend fun refreshToken(): String? {
|
||||||
val refreshToken = tokenManager.getRefreshToken() ?: return null
|
val refreshToken = tokenManager.getRefreshToken() ?: return null
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.digitoolsolutions.app.torquevaultkmp.data.network.api
|
package com.digitoolsolutions.app.torquevaultkmp.data.network.api
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant values for API endpoints.
|
||||||
|
*/
|
||||||
object Endpoints {
|
object Endpoints {
|
||||||
const val AUTH_TOKEN = "/token/"
|
const val AUTH_TOKEN = "/token/"
|
||||||
const val AUTH_REFRESH = "/token/refresh/"
|
const val AUTH_REFRESH = "/token/refresh/"
|
||||||
@@ -9,4 +12,4 @@ object Endpoints {
|
|||||||
const val MEASURE = "measure/"
|
const val MEASURE = "measure/"
|
||||||
const val RE_MEASURE = "remeasure/"
|
const val RE_MEASURE = "remeasure/"
|
||||||
const val CANCEL = "cancel-by-wrench/"
|
const val CANCEL = "cancel-by-wrench/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import io.ktor.http.HttpStatusCode
|
|||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import io.ktor.http.path
|
import io.ktor.http.path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API service for managing work orders and torque measurements.
|
||||||
|
*/
|
||||||
class WorkOrderApi(
|
class WorkOrderApi(
|
||||||
private val client: HttpClient,
|
private val client: HttpClient,
|
||||||
private val tokenManager: TokenManager
|
private val tokenManager: TokenManager
|
||||||
@@ -27,13 +30,19 @@ class WorkOrderApi(
|
|||||||
private val baseUrl: String?
|
private val baseUrl: String?
|
||||||
get() = tokenManager.getServerUrl()
|
get() = tokenManager.getServerUrl()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a list of work orders available for the device.
|
||||||
|
*/
|
||||||
suspend fun fetchAbleWorkOrders(woID: String, action: String): List<WorkOrderDto> {
|
suspend fun fetchAbleWorkOrders(woID: String, action: String): List<WorkOrderDto> {
|
||||||
return client.get("$baseUrl/${Endpoints.ABLE_WO}") {
|
return client.get("$baseUrl/${Endpoints.ABLE_WO}") {
|
||||||
parameter("woID", woID)
|
parameter("wo_id", woID)
|
||||||
parameter("action", action)
|
parameter("action", action)
|
||||||
}.body()
|
}.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirms that a specific device is handling a work order.
|
||||||
|
*/
|
||||||
suspend fun confirm(woID: String, deviceId: String, type: Int): Boolean {
|
suspend fun confirm(woID: String, deviceId: String, type: Int): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val response = client.patch("$baseUrl/${Endpoints.WORK_ORDERS}/$woID/${Endpoints.CONFIRM}"){
|
val response = client.patch("$baseUrl/${Endpoints.WORK_ORDERS}/$woID/${Endpoints.CONFIRM}"){
|
||||||
@@ -46,6 +55,9 @@ class WorkOrderApi(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Uploads measurement results for a specific work order.
|
||||||
|
*/
|
||||||
suspend fun sendMeasurement(woID: String, bodyPayload: SendMeasureDto, isRemeasure: Boolean = false): Result<MeasureResDto> {
|
suspend fun sendMeasurement(woID: String, bodyPayload: SendMeasureDto, isRemeasure: Boolean = false): Result<MeasureResDto> {
|
||||||
return try {
|
return try {
|
||||||
val endPath = if(isRemeasure) Endpoints.RE_MEASURE else Endpoints.MEASURE
|
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.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class WorkOrderDto(
|
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.RefreshRequest
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.domain.model.RefreshResponse
|
import com.digitoolsolutions.app.torquevaultkmp.domain.model.RefreshResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension functions to map Authentication Network DTOs to Domain models.
|
||||||
|
*/
|
||||||
|
|
||||||
fun LoginRequestDto.toDomain(): LoginRequest {
|
fun LoginRequestDto.toDomain(): LoginRequest {
|
||||||
return LoginRequest(username,password)
|
return LoginRequest(username,password)
|
||||||
}
|
}
|
||||||
@@ -21,4 +25,4 @@ fun RefreshRequestDto.toDomain(): RefreshRequest {
|
|||||||
}
|
}
|
||||||
fun RefreshResponseDto.toDomain(): RefreshResponse {
|
fun RefreshResponseDto.toDomain(): RefreshResponse {
|
||||||
return RefreshResponse(access)
|
return RefreshResponse(access)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasResult
|
|||||||
import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult
|
import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.domain.model.Service
|
import com.digitoolsolutions.app.torquevaultkmp.domain.model.Service
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
|
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 {
|
fun WorkOrderDto.toDomain(): WorkOrder {
|
||||||
return WorkOrder(
|
return WorkOrder(
|
||||||
@@ -46,10 +48,11 @@ fun WorkOrderDto.toDomain(): WorkOrder {
|
|||||||
updatedAt = updatedAt
|
updatedAt = updatedAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MeasureResDto.toDomain(): MeasureResult {
|
fun MeasureResDto.toDomain(): MeasureResult {
|
||||||
return MeasureResult(
|
return MeasureResult(
|
||||||
status = status,
|
status = status,
|
||||||
message = message,
|
message = message,
|
||||||
data = data.toDomain()
|
data = data.toDomain()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,24 +8,48 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.flow.Flow
|
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(
|
class DeviceRepository(
|
||||||
private val dbQueries: AppDatabaseQueries
|
private val dbQueries: AppDatabaseQueries
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val PAGE_SIZE = 20L
|
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) {
|
suspend fun saveDevice(id: String, name: String) {
|
||||||
dbQueries.insertDevice(id, name)
|
dbQueries.insertDevice(id, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a flow of all saved devices.
|
||||||
|
*/
|
||||||
fun getAllDevices(): Flow<List<Device>> =
|
fun getAllDevices(): Flow<List<Device>> =
|
||||||
dbQueries.getAllDevices()
|
dbQueries.getAllDevices()
|
||||||
.asFlow()
|
.asFlow()
|
||||||
.mapToList(Dispatchers.IO)
|
.mapToList(Dispatchers.IO)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a flow containing only the identifiers of all saved devices.
|
||||||
|
*/
|
||||||
suspend fun getAllDeviceIdsFlow(): Flow<List<String>> {
|
suspend fun getAllDeviceIdsFlow(): Flow<List<String>> {
|
||||||
return dbQueries.getAllDeviceIds().asFlow().mapToList(Dispatchers.IO)
|
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> {
|
suspend fun getDevicesPaged(limit: Long = PAGE_SIZE, lastId: String? = null): List<Device> {
|
||||||
return if (lastId == null) {
|
return if (lastId == null) {
|
||||||
dbQueries.getDevicesFirstPage(limit).executeAsList()
|
dbQueries.getDevicesFirstPage(limit).executeAsList()
|
||||||
@@ -34,8 +58,14 @@ class DeviceRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a specific device by its identifier.
|
||||||
|
*/
|
||||||
suspend fun getDeviceById(id: String): Device? =
|
suspend fun getDeviceById(id: String): Device? =
|
||||||
dbQueries.getDeviceById(id).executeAsOneOrNull()
|
dbQueries.getDeviceById(id).executeAsOneOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a device from the bonded list.
|
||||||
|
*/
|
||||||
suspend fun deleteDevice(id: String) = dbQueries.deleteDeviceById(id)
|
suspend fun deleteDevice(id: String) = dbQueries.deleteDeviceById(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ import kotlin.time.Clock
|
|||||||
import kotlin.uuid.ExperimentalUuidApi
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
import kotlin.uuid.Uuid
|
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(
|
data class Log(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -19,12 +29,25 @@ data class Log(
|
|||||||
val deviceId: String?,
|
val deviceId: String?,
|
||||||
val source: 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() {
|
class LogRepository() {
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAX_LOGS = 1000
|
private const val MAX_LOGS = 1000
|
||||||
}
|
}
|
||||||
private val _logs = MutableStateFlow<List<Log>>(emptyList())
|
private val _logs = MutableStateFlow<List<Log>>(emptyList())
|
||||||
val logs: StateFlow<List<Log>> = _logs
|
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)
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
fun appendLog(title: String, content: String, deviceId: String? = null) {
|
fun appendLog(title: String, content: String, deviceId: String? = null) {
|
||||||
val source = if (deviceId == null) LogFilter.SYSTEM else LogFilter.DEVICE
|
val source = if (deviceId == null) LogFilter.SYSTEM else LogFilter.DEVICE
|
||||||
|
|||||||
@@ -1,25 +1,40 @@
|
|||||||
package com.digitoolsolutions.app.torquevaultkmp.data.repository
|
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.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.MeasureResult
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
|
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) {
|
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> {
|
suspend fun getAbleWorkOrders(woID: String = "0", action: String = "next"): List<WorkOrder> {
|
||||||
return woRepository.fetchAbleWorkOrders(woID, action)
|
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 {
|
suspend fun confirmWorkOrder(woID: String, deviceId: String, type: Int): Boolean {
|
||||||
return woRepository.confirm(woID, deviceId, type)
|
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> {
|
suspend fun sendMeasurementResult(woId: String, data: SendMeasureDto, isReTorque: Boolean = false): Result<MeasureResult> {
|
||||||
return woRepository.sendMeasurement(woId, data, isReTorque)
|
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> {
|
suspend fun sendCancelWorkOrder(woId: String, data: SendMeasureDto): Result<WorkOrder> {
|
||||||
return woRepository.cancelWorkOrder(woId, data)
|
return woRepository.cancelWorkOrder(woId, data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.digitoolsolutions.app.torquevaultkmp.data.repository
|
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.data.network.dto.SendMeasureDto
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult
|
import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
|
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface defining the operations for managing work orders via remote API.
|
||||||
|
*/
|
||||||
interface WorkOrderRepository {
|
interface WorkOrderRepository {
|
||||||
suspend fun fetchAbleWorkOrders(woId: String, action: String): List<WorkOrder>
|
suspend fun fetchAbleWorkOrders(woId: String, action: String): List<WorkOrder>
|
||||||
suspend fun confirm(woID: String, deviceId: String, type: Int): Boolean
|
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.data.network.mapper.toDomain
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult
|
import com.digitoolsolutions.app.torquevaultkmp.domain.model.MeasureResult
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
|
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
|
||||||
|
/**
|
||||||
|
* Implementation of [WorkOrderRepository] that communicates with the backend API.
|
||||||
|
*/
|
||||||
class WorkOrderRepositoryImpl(private val api: WorkOrderApi) : WorkOrderRepository {
|
class WorkOrderRepositoryImpl(private val api: WorkOrderApi) : WorkOrderRepository {
|
||||||
override suspend fun fetchAbleWorkOrders(woId: String, action: String): List<WorkOrder> {
|
override suspend fun fetchAbleWorkOrders(woId: String, action: String): List<WorkOrder> {
|
||||||
val dto = api.fetchAbleWorkOrders(woId, action)
|
val dto = api.fetchAbleWorkOrders(woId, action)
|
||||||
@@ -16,7 +19,7 @@ class WorkOrderRepositoryImpl(private val api: WorkOrderApi) : WorkOrderReposito
|
|||||||
deviceId: String,
|
deviceId: String,
|
||||||
type: Int
|
type: Int
|
||||||
): Boolean {
|
): 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> {
|
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 kotlinx.coroutines.flow.Flow
|
||||||
import kotlin.reflect.KClass
|
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 {
|
interface AppStorage {
|
||||||
|
/**
|
||||||
|
* Persists a value for the given key.
|
||||||
|
*/
|
||||||
fun <T : Any> save(key: String, value: T)
|
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?
|
fun <T : Any> load(key: String, type: KClass<T>): T?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the data associated with the given key.
|
||||||
|
*/
|
||||||
fun remove(key: String)
|
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>
|
fun <T : Any> observe(key: String, type: KClass<T>): Flow<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
package com.digitoolsolutions.app.torquevaultkmp.data.storage
|
package com.digitoolsolutions.app.torquevaultkmp.data.storage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants used as keys for persistent storage throughout the application.
|
||||||
|
*/
|
||||||
object ReferKeys {
|
object ReferKeys {
|
||||||
|
/** The access token used for authenticated API requests. */
|
||||||
const val ACCESS_TOKEN = "access_token"
|
const val ACCESS_TOKEN = "access_token"
|
||||||
|
|
||||||
|
/** The refresh token used to obtain new access tokens. */
|
||||||
const val REFRESH_TOKEN = "refresh_token"
|
const val REFRESH_TOKEN = "refresh_token"
|
||||||
|
|
||||||
|
/** The base URL of the remote server. */
|
||||||
const val SERVER_URL = "server_url"
|
const val SERVER_URL = "server_url"
|
||||||
|
|
||||||
|
/** User preference for Dark Mode (Boolean). */
|
||||||
const val THEME = "theme"
|
const val THEME = "theme"
|
||||||
|
|
||||||
|
/** User preference for automatic reconnection to bonded devices (Boolean). */
|
||||||
const val DEVICE_AUTO_CONNECT = "device_auto_connect"
|
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.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
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) {
|
class TokenManager(private val storage: AppStorage) {
|
||||||
|
/**
|
||||||
|
* A flow that emits when the authentication session has expired.
|
||||||
|
*/
|
||||||
private val _sessionExpired = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
private val _sessionExpired = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
val sessionExpired = _sessionExpired.asSharedFlow()
|
val sessionExpired = _sessionExpired.asSharedFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers the session expiration event, typically called by a Ktor interceptor.
|
||||||
|
*/
|
||||||
fun triggerSessionExpired() {
|
fun triggerSessionExpired() {
|
||||||
_sessionExpired.tryEmit(Unit)
|
_sessionExpired.tryEmit(Unit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ package com.digitoolsolutions.app.torquevaultkmp.di
|
|||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import org.koin.dsl.KoinAppDeclaration
|
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) {
|
fun initKoin(config: KoinAppDeclaration? = null) {
|
||||||
startKoin {
|
startKoin {
|
||||||
config?.invoke(this)
|
config?.invoke(this)
|
||||||
|
|||||||
@@ -25,14 +25,23 @@ import org.koin.core.module.dsl.singleOf
|
|||||||
import org.koin.core.qualifier.named
|
import org.koin.core.qualifier.named
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-specific module to be provided by each target (Android, iOS).
|
||||||
|
*/
|
||||||
expect val platformModule: Module
|
expect val platformModule: Module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared application module providing core utility classes.
|
||||||
|
*/
|
||||||
val appModule = module {
|
val appModule = module {
|
||||||
// TokenManager use AppStorage
|
// TokenManager use AppStorage
|
||||||
single { TokenManager(get()) }
|
single { TokenManager(get()) }
|
||||||
singleOf(::BleManager)
|
singleOf(::BleManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared storage module providing database, repositories, and local persistence logic.
|
||||||
|
*/
|
||||||
val storageModule = module {
|
val storageModule = module {
|
||||||
single { AppDatabase(get()) }
|
single { AppDatabase(get()) }
|
||||||
single { get<AppDatabase>().appDatabaseQueries }
|
single { get<AppDatabase>().appDatabaseQueries }
|
||||||
@@ -41,6 +50,9 @@ val storageModule = module {
|
|||||||
single { ScannerRepository(get()) }
|
single { ScannerRepository(get()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared network module providing Ktor clients, API services, and network-bound repositories.
|
||||||
|
*/
|
||||||
val networkModule = module {
|
val networkModule = module {
|
||||||
// Auth client (no interceptor to avoid circular dependency and recursion)
|
// Auth client (no interceptor to avoid circular dependency and recursion)
|
||||||
single(named("authClient")) { createPlatformHttpClient(get(), null) }
|
single(named("authClient")) { createPlatformHttpClient(get(), null) }
|
||||||
@@ -56,6 +68,9 @@ val networkModule = module {
|
|||||||
factory { FetchWorkOrdersUseCase(get()) }
|
factory { FetchWorkOrdersUseCase(get()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared ViewModel module providing state management for the UI screens.
|
||||||
|
*/
|
||||||
val viewModelModule = module {
|
val viewModelModule = module {
|
||||||
factoryOf(::HomeViewModel)
|
factoryOf(::HomeViewModel)
|
||||||
factory { AuthViewModel(get(), get()) }
|
factory { AuthViewModel(get(), get()) }
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
package com.digitoolsolutions.app.torquevaultkmp.domain.model
|
package com.digitoolsolutions.app.torquevaultkmp.domain.model
|
||||||
|
/**
|
||||||
|
* Mapping of wheel positions to bitmask values for the UART protocol.
|
||||||
|
*/
|
||||||
val wheelsCode = mapOf(
|
val wheelsCode = mapOf(
|
||||||
"DF" to 0b0001,
|
"DF" to 0b0001, // Driver Front
|
||||||
"PF" to 0b0010,
|
"PF" to 0b0010, // Passenger Front
|
||||||
"DR" to 0b0100,
|
"DR" to 0b0100, // Driver Rear
|
||||||
"PR" to 0b1000,
|
"PR" to 0b1000, // Passenger Rear
|
||||||
"DRO" to 0b00010000,
|
"DRO" to 0b00010000, // Driver Rear Outer
|
||||||
"PRO" to 0b100000
|
"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 {
|
fun WorkOrder.computeWheelsNuts(): Int {
|
||||||
var wNut = 0
|
var wNut = 0
|
||||||
var nutCount = 0
|
var nutCount = 0
|
||||||
@@ -22,6 +30,9 @@ fun WorkOrder.computeWheelsNuts(): Int {
|
|||||||
return (wNut shl 8) or nutCount
|
return (wNut shl 8) or nutCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain model representing a Work Order.
|
||||||
|
*/
|
||||||
data class WorkOrder(
|
data class WorkOrder(
|
||||||
val id: String,
|
val id: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
@@ -35,6 +46,10 @@ data class WorkOrder(
|
|||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val updatedAt: String?
|
val updatedAt: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Details of a service requested within a [WorkOrder], specifically for torque tasks.
|
||||||
|
*/
|
||||||
data class Service(
|
data class Service(
|
||||||
val nut: Int?,
|
val nut: Int?,
|
||||||
val status: 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.data.repository.WorkOrderRepository
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
|
import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case responsible for retrieving work orders from the repository.
|
||||||
|
*/
|
||||||
class FetchWorkOrdersUseCase(private val repository: WorkOrderRepository) {
|
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> {
|
suspend operator fun invoke(page: Int, woId: String = "0", action: String = "next"): List<WorkOrder> {
|
||||||
return repository.fetchAbleWorkOrders(woId, action)
|
return repository.fetchAbleWorkOrders(woId, action)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,22 @@ import kotlin.uuid.ExperimentalUuidApi
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
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)
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
class BleManager {
|
class BleManager {
|
||||||
companion object {
|
companion object {
|
||||||
|
/** The primary Service UUID for the torque device UART protocol. */
|
||||||
val SERVICE_UUID = Uuid.parse("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
|
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"))
|
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"))
|
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 getAdvertisement(identifier: String): Advertisement? = advertisements[identifier]
|
||||||
fun getAdvertisementName(identifier: String): String = advertisements[identifier]?.name.toString()
|
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 {
|
fun scanDevices(): Flow<Advertisement> = Scanner {
|
||||||
filters {
|
filters {
|
||||||
match {
|
match {
|
||||||
@@ -47,6 +63,12 @@ class BleManager {
|
|||||||
it
|
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 {
|
suspend fun connect(advertisement: Advertisement): Peripheral {
|
||||||
val identifier = advertisement.identifier.toString()
|
val identifier = advertisement.identifier.toString()
|
||||||
Napier.d(">>>>> Connecting to $identifier")
|
Napier.d(">>>>> Connecting to $identifier")
|
||||||
@@ -57,23 +79,37 @@ class BleManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects a peripheral by its identifier.
|
||||||
|
*/
|
||||||
suspend fun disconnect(identifier: String) {
|
suspend fun disconnect(identifier: String) {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
peripherals.remove(identifier)?.disconnect()
|
peripherals.remove(identifier)?.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects all currently active BLE connections.
|
||||||
|
*/
|
||||||
suspend fun disconnectAll() {
|
suspend fun disconnectAll() {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
peripherals.values.forEach { it.disconnect() }
|
peripherals.values.forEach { it.disconnect() }
|
||||||
peripherals.clear()
|
peripherals.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a UART command string to the connected peripheral.
|
||||||
|
*/
|
||||||
suspend fun sendCommand(peripheral: Peripheral, command: String) {
|
suspend fun sendCommand(peripheral: Peripheral, command: String) {
|
||||||
Napier.d(">>>>> Sent to uart: $command")
|
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())
|
peripheral.write(RX_CHAR, cmd.encodeToByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a flow that emits incoming UART data from the peripheral.
|
||||||
|
*/
|
||||||
fun observeRx(peripheral: Peripheral): Flow<String> =
|
fun observeRx(peripheral: Peripheral): Flow<String> =
|
||||||
peripheral.observe(TX_CHAR).map { it.decodeToString() }
|
peripheral.observe(TX_CHAR).map { it.decodeToString() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,24 @@ package com.digitoolsolutions.app.torquevaultkmp.screens
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import org.jetbrains.compose.resources.DrawableResource
|
import org.jetbrains.compose.resources.DrawableResource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for different types of icons used in the navigation system.
|
||||||
|
*/
|
||||||
sealed class AppIcon {
|
sealed class AppIcon {
|
||||||
|
/** Uses a compiled Compose Multiplatform resource. */
|
||||||
data class Resource(val resId: DrawableResource) : AppIcon()
|
data class Resource(val resId: DrawableResource) : AppIcon()
|
||||||
|
|
||||||
|
/** Uses a standard Material [ImageVector]. */
|
||||||
data class Vector(val imageVector: ImageVector) : AppIcon()
|
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(
|
open class Destinations(
|
||||||
val label: String,
|
val label: String,
|
||||||
val icon: AppIcon,
|
val icon: AppIcon,
|
||||||
val route: String = label.lowercase().replace(" ", "_")
|
val route: String = label.lowercase().replace(" ", "_")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.compose.material3.NavigationBarItemDefaults
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -53,7 +52,7 @@ fun MainScreen(
|
|||||||
isLoggedIn: Boolean,
|
isLoggedIn: Boolean,
|
||||||
viewModel: AppViewModel = koinInject()
|
viewModel: AppViewModel = koinInject()
|
||||||
) {
|
) {
|
||||||
val showLogs by viewModel.showLogs.collectAsState()
|
val showLogs by viewModel.showLogs
|
||||||
val destinations = buildList {
|
val destinations = buildList {
|
||||||
add(HomeDestination)
|
add(HomeDestination)
|
||||||
add(ScannerDestination)
|
add(ScannerDestination)
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the state of the login process.
|
||||||
|
*/
|
||||||
sealed class LoginUiState {
|
sealed class LoginUiState {
|
||||||
object Idle : LoginUiState()
|
object Idle : LoginUiState()
|
||||||
object Loading : LoginUiState()
|
object Loading : LoginUiState()
|
||||||
@@ -15,21 +18,39 @@ sealed class LoginUiState {
|
|||||||
data class Error(val message: String) : 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(
|
class AuthViewModel(
|
||||||
private val authApi: AuthApi,
|
private val authApi: AuthApi,
|
||||||
private val tokenManager: TokenManager
|
private val tokenManager: TokenManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable state of the login operation.
|
||||||
|
*/
|
||||||
private val _loginState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
|
private val _loginState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
|
||||||
val loginState = _loginState.asStateFlow()
|
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")
|
private val _serverUrl = MutableStateFlow(tokenManager.getBaseUrl() ?: "http://digitoolsolutions.synology.me:3000")
|
||||||
val serverUrl = _serverUrl.asStateFlow()
|
val serverUrl = _serverUrl.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the server URL in the UI state.
|
||||||
|
*/
|
||||||
fun onChangeServerUrl(url: String) {
|
fun onChangeServerUrl(url: String) {
|
||||||
_serverUrl.value = url
|
_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) {
|
fun login(username: String, password: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_loginState.value = LoginUiState.Loading
|
_loginState.value = LoginUiState.Loading
|
||||||
@@ -39,7 +60,27 @@ class AuthViewModel(
|
|||||||
if (success) {
|
if (success) {
|
||||||
_loginState.value = LoginUiState.Success
|
_loginState.value = LoginUiState.Success
|
||||||
} else {
|
} 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) {
|
} catch (e: Exception) {
|
||||||
_loginState.value = LoginUiState.Error("Error: ${e.message}")
|
_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.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -33,6 +34,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
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.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeDestination
|
import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeDestination
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -61,13 +62,19 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import torquevaultkmp.composeapp.generated.resources.Res
|
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.btn_login
|
||||||
import torquevaultkmp.composeapp.generated.resources.dts_text
|
import torquevaultkmp.composeapp.generated.resources.dts_text
|
||||||
import torquevaultkmp.composeapp.generated.resources.lbl_password
|
import torquevaultkmp.composeapp.generated.resources.lbl_password
|
||||||
import torquevaultkmp.composeapp.generated.resources.lbl_server_url
|
import torquevaultkmp.composeapp.generated.resources.lbl_server_url
|
||||||
import torquevaultkmp.composeapp.generated.resources.lbl_username
|
import torquevaultkmp.composeapp.generated.resources.lbl_username
|
||||||
import torquevaultkmp.composeapp.generated.resources.server_url_placeholder
|
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
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
navController: NavController
|
navController: NavController
|
||||||
@@ -77,12 +84,15 @@ fun LoginScreen(
|
|||||||
val serverUrl by viewModel.serverUrl.collectAsState()
|
val serverUrl by viewModel.serverUrl.collectAsState()
|
||||||
|
|
||||||
LoginScreenContent(
|
LoginScreenContent(
|
||||||
viewModel = viewModel,
|
|
||||||
loginState = loginState,
|
loginState = loginState,
|
||||||
serverUrl = serverUrl,
|
serverUrl = serverUrl,
|
||||||
|
onServerUrlChange = { viewModel.onChangeServerUrl(it) },
|
||||||
onLogin = { username, password ->
|
onLogin = { username, password ->
|
||||||
viewModel.login(username, password)
|
viewModel.login(username, password)
|
||||||
},
|
},
|
||||||
|
onLoginWithApiKey = { apiKey ->
|
||||||
|
viewModel.loginWithApiKey(apiKey)
|
||||||
|
},
|
||||||
onLoginSuccess = {
|
onLoginSuccess = {
|
||||||
navController.navigate(HomeDestination.route) {
|
navController.navigate(HomeDestination.route) {
|
||||||
popUpTo(AuthDestination.route) { inclusive = true }
|
popUpTo(AuthDestination.route) { inclusive = true }
|
||||||
@@ -92,19 +102,25 @@ fun LoginScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateless UI content for the login screen.
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreenContent(
|
fun LoginScreenContent(
|
||||||
viewModel: AuthViewModel,
|
|
||||||
loginState: LoginUiState,
|
loginState: LoginUiState,
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
|
onServerUrlChange: (String) -> Unit,
|
||||||
onLogin: (String, String) -> Unit,
|
onLogin: (String, String) -> Unit,
|
||||||
|
onLoginWithApiKey: (String) -> Unit,
|
||||||
onLoginSuccess: () -> Unit
|
onLoginSuccess: () -> Unit
|
||||||
) {
|
) {
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
var username by remember { mutableStateOf("admin") }
|
var username by remember { mutableStateOf("admin") }
|
||||||
var password 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) }
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -159,70 +175,107 @@ fun LoginScreenContent(
|
|||||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||||
),
|
),
|
||||||
value = serverUrl,
|
value = serverUrl,
|
||||||
onValueChange = { viewModel.onChangeServerUrl(it) },
|
onValueChange = { onServerUrlChange(it) },
|
||||||
label = { Text(stringResource(Res.string.lbl_server_url)) },
|
label = { Text(stringResource(Res.string.lbl_server_url)) },
|
||||||
placeholder = { Text(stringResource(Res.string.server_url_placeholder)) }
|
placeholder = { Text(stringResource(Res.string.server_url_placeholder)) }
|
||||||
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
OutlinedTextField(
|
if (!useApiKey) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
/* Use username and password */
|
||||||
singleLine = true,
|
OutlinedTextField(
|
||||||
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
keyboardActions = KeyboardActions(
|
singleLine = true,
|
||||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next),
|
||||||
),
|
keyboardActions = KeyboardActions(
|
||||||
value = username,
|
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||||
onValueChange = { username = it },
|
),
|
||||||
label = { Text(stringResource(Res.string.lbl_username)) }
|
value = username,
|
||||||
)
|
onValueChange = { username = it },
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
label = { Text(stringResource(Res.string.lbl_username)) }
|
||||||
OutlinedTextField(
|
)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
singleLine = true,
|
OutlinedTextField(
|
||||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
trailingIcon = {
|
singleLine = true,
|
||||||
val image =
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff
|
trailingIcon = {
|
||||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
val image =
|
||||||
Icon(
|
if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff
|
||||||
imageVector = image,
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
contentDescription = if (passwordVisible) "Hide password" else "Show password"
|
Icon(
|
||||||
)
|
imageVector = image,
|
||||||
}
|
contentDescription = if (passwordVisible) "Hide password" else "Show password"
|
||||||
},
|
)
|
||||||
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
|
}
|
||||||
keyboardActions = KeyboardActions(
|
},
|
||||||
onDone = {
|
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
|
||||||
if (username.isBlank() || password.isBlank()) {
|
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 {
|
scope.launch {
|
||||||
snackbarHostState.showSnackbar("Username and Password is required!")
|
snackbarHostState.showSnackbar("API Key is required!")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
onLogin(username, password)
|
onLoginWithApiKey(apiKey.trim())
|
||||||
keyboardController?.hide()
|
keyboardController?.hide()
|
||||||
focusManager.clearFocus()
|
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 {
|
} else {
|
||||||
onLogin(username.trim(), password.trim())
|
if (username.isBlank() || password.isBlank()) {
|
||||||
keyboardController?.hide()
|
scope.launch {
|
||||||
focusManager.clearFocus()
|
snackbarHostState.showSnackbar("Username and password is required!")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onLogin(username.trim(), password.trim())
|
||||||
|
keyboardController?.hide()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = loginState !is LoginUiState.Loading
|
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
|
@Composable
|
||||||
private fun LoginScreenPreview() {
|
private fun LoginScreenPreview() {
|
||||||
LoginScreenContent(
|
LoginScreenContent(
|
||||||
viewModel = viewModel(),
|
|
||||||
loginState = LoginUiState.Idle,
|
loginState = LoginUiState.Idle,
|
||||||
serverUrl = "http://localhost:3000",
|
serverUrl = "http://localhost:3000",
|
||||||
|
onServerUrlChange = {},
|
||||||
onLogin = {_, _ -> },
|
onLogin = {_, _ -> },
|
||||||
|
onLoginWithApiKey = {},
|
||||||
onLoginSuccess = {}
|
onLoginSuccess = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,27 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.random.Random
|
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(
|
class BondedViewModel(
|
||||||
private val deviceRepository: DeviceRepository
|
private val deviceRepository: DeviceRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable list of bonded devices.
|
||||||
|
*/
|
||||||
private val _bondedDevices = MutableStateFlow<List<Device>>(emptyList())
|
private val _bondedDevices = MutableStateFlow<List<Device>>(emptyList())
|
||||||
val bondedDevices: StateFlow<List<Device>> = _bondedDevices.asStateFlow()
|
val bondedDevices: StateFlow<List<Device>> = _bondedDevices.asStateFlow()
|
||||||
|
|
||||||
private var lastId: String? = null
|
private var lastId: String? = null
|
||||||
private val pageSize = DeviceRepository.PAGE_SIZE
|
private val pageSize = DeviceRepository.PAGE_SIZE
|
||||||
private var isLastPage = false
|
private var isLastPage = false
|
||||||
|
|
||||||
|
/** Indicates if a background data fetch is currently in progress. */
|
||||||
var isLoading = MutableStateFlow(false)
|
var isLoading = MutableStateFlow(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -27,6 +38,9 @@ class BondedViewModel(
|
|||||||
loadNextPage()
|
loadNextPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the next page of bonded devices from the database.
|
||||||
|
*/
|
||||||
fun loadNextPage() {
|
fun loadNextPage() {
|
||||||
if (isLoading.value || isLastPage) return
|
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_cancel
|
||||||
import torquevaultkmp.composeapp.generated.resources.action_retry
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun UartCommunicationScreen(
|
fun UartCommunicationScreen(
|
||||||
@@ -122,4 +128,4 @@ fun UartCommunicationScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ import torquevaultkmp.composeapp.generated.resources.Res
|
|||||||
import torquevaultkmp.composeapp.generated.resources.home_title
|
import torquevaultkmp.composeapp.generated.resources.home_title
|
||||||
import kotlin.uuid.ExperimentalUuidApi
|
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)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
|
|||||||
@@ -12,11 +12,24 @@ import kotlinx.coroutines.flow.asSharedFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
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(
|
class HomeViewModel(
|
||||||
private val fetchWorkOrdersUseCase: FetchWorkOrdersUseCase
|
private val fetchWorkOrdersUseCase: FetchWorkOrdersUseCase
|
||||||
): ViewModel() {
|
): ViewModel() {
|
||||||
|
/**
|
||||||
|
* Observable list of work orders.
|
||||||
|
*/
|
||||||
private val _workOrders = MutableStateFlow<List<WorkOrder>>(emptyList())
|
private val _workOrders = MutableStateFlow<List<WorkOrder>>(emptyList())
|
||||||
val workOrders = _workOrders.asStateFlow()
|
val workOrders = _workOrders.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the initial page of work orders.
|
||||||
|
*/
|
||||||
fun loadWorkOrders() {
|
fun loadWorkOrders() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||||||
import torquevaultkmp.composeapp.generated.resources.Res
|
import torquevaultkmp.composeapp.generated.resources.Res
|
||||||
import torquevaultkmp.composeapp.generated.resources.log_title
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LogScreen(
|
fun LogScreen(
|
||||||
@@ -80,6 +86,9 @@ fun LogScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateless UI content for the diagnostic logs screen.
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LogContent(
|
internal fun LogContent(
|
||||||
|
|||||||
@@ -29,15 +29,27 @@ import com.digitoolsolutions.app.torquevaultkmp.utils.NetworkStatus
|
|||||||
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
|
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class representing the current state of system requirements.
|
||||||
|
*/
|
||||||
data class Requirements(
|
data class Requirements(
|
||||||
|
/** Whether the device supports Bluetooth Low Energy. */
|
||||||
val hasBleFeature: Boolean,
|
val hasBleFeature: Boolean,
|
||||||
|
/** Whether Bluetooth is currently turned on. */
|
||||||
val isEnabled: Boolean,
|
val isEnabled: Boolean,
|
||||||
|
/** Whether the app has been granted necessary Bluetooth/Location permissions. */
|
||||||
val hasPermission: Boolean,
|
val hasPermission: Boolean,
|
||||||
|
/** Whether the user has permanently denied the required permissions. */
|
||||||
val hasPermanentlyDenied: Boolean,
|
val hasPermanentlyDenied: Boolean,
|
||||||
|
/** Whether Location services are enabled (required for BLE on some Android versions). */
|
||||||
val isLocationEnabled: Boolean,
|
val isLocationEnabled: Boolean,
|
||||||
|
/** Whether the device has active internet connectivity. */
|
||||||
val networkAvailable: Boolean,
|
val networkAvailable: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sealed class representing the mutually exclusive states of requirement fulfillment.
|
||||||
|
*/
|
||||||
sealed class RequirementState {
|
sealed class RequirementState {
|
||||||
object BleUnsupported : RequirementState()
|
object BleUnsupported : RequirementState()
|
||||||
object BluetoothDisabled : RequirementState()
|
object BluetoothDisabled : RequirementState()
|
||||||
@@ -48,6 +60,9 @@ sealed class RequirementState {
|
|||||||
object Ready : RequirementState()
|
object Ready : RequirementState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the raw [Requirements] data into a simplified [RequirementState].
|
||||||
|
*/
|
||||||
fun Requirements.toState(): RequirementState {
|
fun Requirements.toState(): RequirementState {
|
||||||
return when {
|
return when {
|
||||||
!hasBleFeature -> RequirementState.BleUnsupported
|
!hasBleFeature -> RequirementState.BleUnsupported
|
||||||
@@ -64,6 +79,16 @@ fun Requirements.toState(): RequirementState {
|
|||||||
else -> RequirementState.Ready
|
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
|
@Composable
|
||||||
fun RequirementWrapper(
|
fun RequirementWrapper(
|
||||||
onReady: () -> Unit,
|
onReady: () -> Unit,
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ import torquevaultkmp.composeapp.generated.resources.scan_empty_title
|
|||||||
import torquevaultkmp.composeapp.generated.resources.scanner_title
|
import torquevaultkmp.composeapp.generated.resources.scanner_title
|
||||||
import kotlin.uuid.ExperimentalUuidApi
|
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)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ScannerScreen(
|
fun ScannerScreen(
|
||||||
@@ -113,12 +119,11 @@ fun ScannerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LaunchedEffect(Unit) {
|
|
||||||
// viewModel.startScan()
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list item representing a discovered Bluetooth device.
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalUuidApi::class)
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DeviceListItem(
|
fun DeviceListItem(
|
||||||
|
|||||||
@@ -111,8 +111,14 @@ class ScannerViewModel(
|
|||||||
private var stabilized = false
|
private var stabilized = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record advertisement: collect (20 times) intervals between consecutive ads
|
* Records advertisement intervals to calibrate the cleanup expiration time.
|
||||||
* to calibrate the cleanup expiration time when a device stops advertising.
|
*
|
||||||
|
* 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) {
|
private fun calibrateCleanupTime(deviceId: String, now: TimeMark) {
|
||||||
if (stabilized) return
|
if (stabilized) return
|
||||||
@@ -171,11 +177,12 @@ class ScannerViewModel(
|
|||||||
|
|
||||||
bleManager.scanDevices()
|
bleManager.scanDevices()
|
||||||
.catch {
|
.catch {
|
||||||
|
Napier.d(">>>>> bleManager.scanDevices catch block")
|
||||||
_isScanning.value = false
|
_isScanning.value = false
|
||||||
stopCleanupJob()
|
stopCleanupJob()
|
||||||
}
|
}
|
||||||
.collect { advertisement ->
|
.collect { advertisement ->
|
||||||
if (advertisement.name == null) return@collect
|
// if (advertisement.name == null) return@collect
|
||||||
val bleId: String = advertisement.identifier.toString()
|
val bleId: String = advertisement.identifier.toString()
|
||||||
if(_autoConnect.value && isBonded(bleId)){
|
if(_autoConnect.value && isBonded(bleId)){
|
||||||
connect(advertisement)
|
connect(advertisement)
|
||||||
@@ -246,6 +253,9 @@ class ScannerViewModel(
|
|||||||
val adv = bleManager.getAdvertisement(deviceId)
|
val adv = bleManager.getAdvertisement(deviceId)
|
||||||
if (adv != null) {
|
if (adv != null) {
|
||||||
connect(adv)
|
connect(adv)
|
||||||
|
// Remove advertisement from list
|
||||||
|
advertisementMap.remove(deviceId)
|
||||||
|
updateDeviceList()
|
||||||
} else {
|
} else {
|
||||||
Napier.e("Cannot connect: Advertisement not found for $deviceId")
|
Napier.e("Cannot connect: Advertisement not found for $deviceId")
|
||||||
}
|
}
|
||||||
@@ -285,9 +295,10 @@ class ScannerViewModel(
|
|||||||
fun sendCommand(peripheral: Peripheral, command: String) {
|
fun sendCommand(peripheral: Peripheral, command: String) {
|
||||||
val deviceId = peripheral.identifier.toString()
|
val deviceId = peripheral.identifier.toString()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
bleManager.sendCommand(peripheral, Helper.buildUartCommand(command))
|
val data = Helper.buildUartCommand(command)
|
||||||
appendHistory(deviceId, "Sent: $command")
|
bleManager.sendCommand(peripheral, data)
|
||||||
logRepository.appendLog(title = "Communicate - Sent", content = command, deviceId)
|
appendHistory(deviceId, "Sent: $data")
|
||||||
|
logRepository.appendLog(title = "Communicate - Sent", content = data, deviceId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,111 +372,125 @@ class ScannerViewModel(
|
|||||||
_connectedDevices.value -= deviceIdentifier
|
_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 {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val orders = scannerRepository.getAbleWorkOrders(woID = woID, action = action)
|
val orders = scannerRepository.getAbleWorkOrders(woID = woID, action = action)
|
||||||
_workOrders.value = orders
|
_workOrders.value = orders
|
||||||
sendWorkOrdersToDevice(peripheral,orders)
|
sendWorkOrdersToDevice(peripheral, command.name, orders)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Napier.e("Failed to load work orders", e)
|
Napier.e("Failed to load work orders", e)
|
||||||
logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
|
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 {
|
viewModelScope.launch {
|
||||||
if (workOrders.isEmpty()) {
|
if (workOrders.isEmpty()) {
|
||||||
sendCommand(peripheral, Helper.CMD_EMPTY_WO)
|
sendCommand(peripheral, Helper.CMD_EMPTY_WO(command))
|
||||||
return@launch
|
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 torque = wo.services?.values?.firstOrNull()?.torqueRequired ?: 0.0
|
||||||
val actionCode = if (wo.status.lowercase() == "open") 0 else 1
|
val actionCode = if (wo.status.lowercase() == "open") 0 else 1
|
||||||
val wheelsMask = wo.computeWheelsNuts()
|
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)
|
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) {
|
private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) {
|
||||||
appendHistory(deviceIdentifier, "Received: $msg")
|
appendHistory(deviceIdentifier, "Received: $msg")
|
||||||
logRepository.appendLog(title = "Communicate - Received", content = msg, deviceId = deviceIdentifier)
|
logRepository.appendLog(title = "Communicate - Received", content = msg, deviceId = deviceIdentifier)
|
||||||
val decoded = Helper.decodeRxData(msg) ?: return
|
val decoded = Helper.decodeRxData(msg) ?: return
|
||||||
val data = decoded.data
|
Napier.d(">>>>> decoded rx message: $decoded")
|
||||||
val woId = decoded.id
|
val cmd = Helper.Command.fromString(decoded.command)
|
||||||
val cmd = data.take(4)
|
val type = Helper.TypeOfDevice.fromString(decoded.type)
|
||||||
val action = if (cmd == "back") "back" else "next"
|
if(type != null) {
|
||||||
viewModelScope.launch {
|
val woId = decoded.woId
|
||||||
|
val data = decoded.data
|
||||||
when (cmd) {
|
when (cmd) {
|
||||||
"requ", "next", "back" -> fetchWorkOrders(peripheral, woId, action)
|
Helper.Command.START_WO -> fetchWorkOrders(peripheral, Helper.Command.START_WO)
|
||||||
"conf" -> {
|
Helper.Command.NEXT_WO -> fetchWorkOrders(peripheral, Helper.Command.NEXT_WO, woId, "next")
|
||||||
try {
|
Helper.Command.PREVIOUS_WO -> fetchWorkOrders(peripheral, Helper.Command.PREVIOUS_WO, woId, "back")
|
||||||
val res = scannerRepository.confirmWorkOrder(woId, deviceIdentifier, 0)
|
Helper.Command.ACCEPT_WO -> acceptWorkOrder(peripheral, type.code, woId, deviceIdentifier)
|
||||||
var command = Helper.CMD_NOT_AVAILABLE_WO
|
Helper.Command.FINISH_WO -> {
|
||||||
if (res)
|
finishWorkOrder(peripheral, type.code, woId, data, deviceIdentifier)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
null -> Napier.w("Unknown command: $cmd")
|
||||||
}
|
}
|
||||||
}
|
} else Napier.w("Unknown type: $type")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addBondedDevice(id: String, name: String) {
|
fun addBondedDevice(id: String, name: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
deviceRepository.saveDevice(id, name)
|
deviceRepository.saveDevice(id, name)
|
||||||
|
|||||||
@@ -69,13 +69,13 @@ fun SettingScreen(
|
|||||||
val serverUrl by viewModel.serverUrl.collectAsState()
|
val serverUrl by viewModel.serverUrl.collectAsState()
|
||||||
val token by viewModel.refreshToken.collectAsState()
|
val token by viewModel.refreshToken.collectAsState()
|
||||||
val autoConnect by viewModel.autoConnect.collectAsState()
|
val autoConnect by viewModel.autoConnect.collectAsState()
|
||||||
val showLogs by appViewModel.showLogs.collectAsState()
|
val showLogs by appViewModel.showLogs
|
||||||
|
|
||||||
SettingContent(
|
SettingContent(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onThemeToggle = onThemeToggle,
|
onThemeToggle = onThemeToggle,
|
||||||
isShowLogs = showLogs,
|
isShowLogs = showLogs,
|
||||||
onToggleLogs = { appViewModel.setShowLogs(it) },
|
onToggleLogs = { appViewModel.toggleShowLogs(it) },
|
||||||
serverUrl = serverUrl,
|
serverUrl = serverUrl,
|
||||||
onServerUrlChange = { viewModel.updateServerUrl(it) },
|
onServerUrlChange = { viewModel.updateServerUrl(it) },
|
||||||
token = token,
|
token = token,
|
||||||
|
|||||||
@@ -9,36 +9,60 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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(
|
class SettingViewModel(
|
||||||
private val tokenManager: TokenManager,
|
private val tokenManager: TokenManager,
|
||||||
private val storage: AppStorage
|
private val storage: AppStorage
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
/** The base server URL. */
|
||||||
private val _serverUrl = MutableStateFlow(tokenManager.getBaseUrl() ?: "")
|
private val _serverUrl = MutableStateFlow(tokenManager.getBaseUrl() ?: "")
|
||||||
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
||||||
|
|
||||||
|
/** The current refresh token. */
|
||||||
private val _refreshToken = MutableStateFlow(tokenManager.getRefreshToken() ?: "")
|
private val _refreshToken = MutableStateFlow(tokenManager.getRefreshToken() ?: "")
|
||||||
val refreshToken: StateFlow<String> = _refreshToken.asStateFlow()
|
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)
|
private val _autoConnect = MutableStateFlow(storage.load<Boolean>(ReferKeys.DEVICE_AUTO_CONNECT) ?: false)
|
||||||
val autoConnect: StateFlow<Boolean> = _autoConnect.asStateFlow()
|
val autoConnect: StateFlow<Boolean> = _autoConnect.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the server URL in the UI state.
|
||||||
|
*/
|
||||||
fun updateServerUrl(url: String) {
|
fun updateServerUrl(url: String) {
|
||||||
_serverUrl.value = url
|
_serverUrl.value = url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the refresh token in the UI state.
|
||||||
|
*/
|
||||||
fun updateRefreshToken(token: String) {
|
fun updateRefreshToken(token: String) {
|
||||||
_refreshToken.value = token
|
_refreshToken.value = token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the current server URL and tokens to storage.
|
||||||
|
*/
|
||||||
fun saveSettings() {
|
fun saveSettings() {
|
||||||
tokenManager.saveServerUrl(_serverUrl.value)
|
tokenManager.saveServerUrl(_serverUrl.value)
|
||||||
tokenManager.saveTokens("", _refreshToken.value)
|
tokenManager.saveTokens("", _refreshToken.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all authentication tokens and logs the user out.
|
||||||
|
*/
|
||||||
fun logout() {
|
fun logout() {
|
||||||
tokenManager.clearTokens()
|
tokenManager.clearTokens()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the auto-connect preference and persists it.
|
||||||
|
*/
|
||||||
fun updateAutoConnect(enabled: Boolean) {
|
fun updateAutoConnect(enabled: Boolean) {
|
||||||
_autoConnect.value = enabled
|
_autoConnect.value = enabled
|
||||||
storage.save(ReferKeys.DEVICE_AUTO_CONNECT, enabled)
|
storage.save(ReferKeys.DEVICE_AUTO_CONNECT, enabled)
|
||||||
|
|||||||
@@ -2,10 +2,23 @@ package com.digitoolsolutions.app.torquevaultkmp.utils
|
|||||||
|
|
||||||
import io.ktor.http.HttpStatusCode
|
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(
|
class ApiException(val status: HttpStatusCode, val body: String) : Exception(
|
||||||
"API error ${status.value}: $body"
|
"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 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
|
package com.digitoolsolutions.app.torquevaultkmp.utils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface providing basic application metadata.
|
||||||
|
*/
|
||||||
interface AppInfo {
|
interface AppInfo {
|
||||||
|
/**
|
||||||
|
* The human-readable version name of the application (e.g., "1.0.2").
|
||||||
|
*/
|
||||||
val version: String?
|
val version: String?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,42 @@ package com.digitoolsolutions.app.torquevaultkmp.utils
|
|||||||
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface defining the platform-specific operations for managing Bluetooth state and permissions.
|
||||||
|
*/
|
||||||
interface BluetoothManager {
|
interface BluetoothManager {
|
||||||
|
/**
|
||||||
|
* Observable state indicating if Bluetooth is currently enabled on the device.
|
||||||
|
*/
|
||||||
val isBluetoothEnabled: StateFlow<Boolean>
|
val isBluetoothEnabled: StateFlow<Boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable state indicating if the app has the necessary Bluetooth permissions.
|
||||||
|
*/
|
||||||
val hasBluetoothPermission: StateFlow<Boolean>
|
val hasBluetoothPermission: StateFlow<Boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a check of the current Bluetooth hardware state.
|
||||||
|
*/
|
||||||
fun checkBluetoothState()
|
fun checkBluetoothState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a check of the current Bluetooth permission status.
|
||||||
|
*/
|
||||||
fun checkBluetoothPermission()
|
fun checkBluetoothPermission()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the system settings for the application, allowing the user to grant permissions.
|
||||||
|
*/
|
||||||
fun openSettings()
|
fun openSettings()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests the user to enable Bluetooth if it is currently disabled.
|
||||||
|
*/
|
||||||
fun enableBluetooth()
|
fun enableBluetooth()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create a platform-specific [BluetoothManager].
|
||||||
|
*/
|
||||||
expect fun createBluetoothManager(): BluetoothManager
|
expect fun createBluetoothManager(): BluetoothManager
|
||||||
|
|||||||
@@ -1,39 +1,118 @@
|
|||||||
package com.digitoolsolutions.app.torquevaultkmp.utils
|
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 {
|
object Helper {
|
||||||
const val CMD_EMPTY_WO = $$"$e*\r\n"
|
const val START_CHARACTER ="$"
|
||||||
const val CMD_RECEIVED_WO = $$"$r*\r\n"
|
const val END_LINE_FEED = "#\n"
|
||||||
const val CMD_NOT_AVAILABLE_WO = $$"$n*\r\n"
|
const val SECURITY_CRC16 = "00cr16"
|
||||||
const val CMD_UPLOADED_WO = $$"$u*\r\n"
|
|
||||||
const val END_LINE_FEED = "#\r\n"
|
|
||||||
|
|
||||||
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
|
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 {
|
object TorqueAction {
|
||||||
const val TORQUE = "0"
|
const val TORQUE = "0"
|
||||||
const val RE_TORQUE = "1"
|
const val RE_TORQUE = "1"
|
||||||
const val CANCEL = "2"
|
const val CANCEL = "2"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a decoded message received from a torque device.
|
||||||
|
*/
|
||||||
data class RxMessage(
|
data class RxMessage(
|
||||||
val id: String,
|
val command: String,
|
||||||
val data: 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 {
|
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? {
|
fun decodeRxData(msg: String): RxMessage? {
|
||||||
if (!msg.startsWith("$")) return null
|
/** Check header and footer*/
|
||||||
val payload = msg.drop(1).trimEnd('*', ' ', '\r', '\n')
|
if (!msg.startsWith("$") || !msg.endsWith("#\n")) return null
|
||||||
val parts = payload.split(",", limit = 2)
|
/** Remove $ and trim end */
|
||||||
if (parts.size < 2) return null
|
val payload = msg.drop(1).trimEnd('*', '#',' ', '\r', '\n')
|
||||||
val woID = parts[0]
|
val parts = payload.split(";", limit = 3)
|
||||||
val data = parts[1]
|
if(parts.size < 2) return null
|
||||||
return RxMessage(woID, data)
|
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>> {
|
fun parseWheelTorqueData(torqueData: List<String>, nutsPerWheel: Int): Map<String, List<Double>> {
|
||||||
val result = mutableMapOf<String, List<Double>>()
|
val result = mutableMapOf<String, List<Double>>()
|
||||||
var i = 0
|
var i = 0
|
||||||
@@ -52,6 +131,10 @@ object Helper {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardizes device identifiers (MAC addresses or UUIDs) for display.
|
||||||
|
*/
|
||||||
fun formatDeviceId(deviceId: String?): String {
|
fun formatDeviceId(deviceId: String?): String {
|
||||||
if(deviceId == null) return ""
|
if(deviceId == null) return ""
|
||||||
return if (deviceId.contains(":")) {
|
return if (deviceId.contains(":")) {
|
||||||
|
|||||||
@@ -2,12 +2,29 @@ package com.digitoolsolutions.app.torquevaultkmp.utils
|
|||||||
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the connectivity status of the device.
|
||||||
|
*/
|
||||||
enum class NetworkStatus {
|
enum class NetworkStatus {
|
||||||
Available, Unavailable
|
Available, Unavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for monitoring real-time network connectivity changes.
|
||||||
|
*/
|
||||||
interface NetworkMonitor {
|
interface NetworkMonitor {
|
||||||
|
/**
|
||||||
|
* Observable stream of the current [NetworkStatus].
|
||||||
|
*/
|
||||||
val status: StateFlow<NetworkStatus>
|
val status: StateFlow<NetworkStatus>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the network monitoring process.
|
||||||
|
*/
|
||||||
fun start()
|
fun start()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the network monitoring process.
|
||||||
|
*/
|
||||||
fun stop()
|
fun stop()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ package com.digitoolsolutions.app.torquevaultkmp.utils
|
|||||||
import io.github.aakira.napier.Antilog
|
import io.github.aakira.napier.Antilog
|
||||||
import io.github.aakira.napier.LogLevel
|
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() {
|
class NoOpAntilog : Antilog() {
|
||||||
override fun isEnable(priority: LogLevel, tag: String?): Boolean = false
|
override fun isEnable(priority: LogLevel, tag: String?): Boolean = false
|
||||||
override fun performLog(priority: LogLevel, tag: String?, throwable: Throwable?, message: String?) {}
|
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 androidx.compose.runtime.Composable
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface defining platform-specific logic for handling runtime permissions and hardware features.
|
||||||
|
*/
|
||||||
interface PermissionHandler {
|
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
|
@Composable
|
||||||
fun HandlePermissionRequest(
|
fun HandlePermissionRequest(
|
||||||
onPermissionResult: (Boolean) -> Unit,
|
onPermissionResult: (Boolean) -> Unit,
|
||||||
content: @Composable (requestPermissions: () -> Unit) -> Unit
|
content: @Composable (requestPermissions: () -> Unit) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the device hardware supports Bluetooth Low Energy.
|
||||||
|
*/
|
||||||
val hasBleFeature: Boolean
|
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>
|
val isLocationEnabled: StateFlow<Boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests the user to enable Location services.
|
||||||
|
*/
|
||||||
fun enableLocation()
|
fun enableLocation()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a check of the current Location service state.
|
||||||
|
*/
|
||||||
fun checkLocationState()
|
fun checkLocationState()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
package com.digitoolsolutions.app.torquevaultkmp.utils
|
package com.digitoolsolutions.app.torquevaultkmp.utils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface defining platform-specific logic for sharing log files.
|
||||||
|
*/
|
||||||
interface ShareLog {
|
interface ShareLog {
|
||||||
|
/**
|
||||||
|
* Exports and shares the provided [content] as a plain text file.
|
||||||
|
*/
|
||||||
fun exportTextFile(content: String)
|
fun exportTextFile(content: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports and shares the provided [content] as a CSV file.
|
||||||
|
*/
|
||||||
fun exportCSVFile(content: String)
|
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 androidx.compose.ui.window.ComposeUIViewController
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.di.initKoin
|
import com.digitoolsolutions.app.torquevaultkmp.di.initKoin
|
||||||
|
import com.juul.kable.CentralManager
|
||||||
import io.github.aakira.napier.DebugAntilog
|
import io.github.aakira.napier.DebugAntilog
|
||||||
import io.github.aakira.napier.Napier
|
import io.github.aakira.napier.Napier
|
||||||
|
|
||||||
@@ -9,6 +10,9 @@ fun MainViewController() = ComposeUIViewController(
|
|||||||
configure = {
|
configure = {
|
||||||
// Add logger lib
|
// Add logger lib
|
||||||
Napier.base(DebugAntilog()) // NoOpAntilog() for release
|
Napier.base(DebugAntilog()) // NoOpAntilog() for release
|
||||||
|
CentralManager.configure {
|
||||||
|
stateRestoration = true
|
||||||
|
}
|
||||||
initKoin()
|
initKoin()
|
||||||
}
|
}
|
||||||
) { App() }
|
) { App() }
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
package com.digitoolsolutions.app.torquevaultkmp
|
package com.digitoolsolutions.app.torquevaultkmp
|
||||||
|
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi
|
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.data.storage.TokenManager
|
||||||
import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException
|
import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException
|
||||||
import io.github.aakira.napier.Napier
|
import io.github.aakira.napier.Napier
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.darwin.Darwin
|
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.HttpTimeout
|
||||||
import io.ktor.client.plugins.auth.Auth
|
import io.ktor.client.plugins.auth.Auth
|
||||||
import io.ktor.client.plugins.auth.providers.BearerTokens
|
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.LogLevel
|
||||||
import io.ktor.client.plugins.logging.Logger
|
import io.ktor.client.plugins.logging.Logger
|
||||||
import io.ktor.client.plugins.logging.Logging
|
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 io.ktor.serialization.kotlinx.json.json
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
actual fun createPlatformHttpClient(
|
actual fun createPlatformHttpClient(
|
||||||
@@ -45,6 +39,9 @@ actual fun createPlatformHttpClient(
|
|||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
refreshTokens {
|
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...")
|
Napier.d(">>>>> [NetworkEngine.ios.kt] Access token expired, auto request refresh token...")
|
||||||
val newAccess = authApi?.refreshToken()
|
val newAccess = authApi?.refreshToken()
|
||||||
if (newAccess != null) {
|
if (newAccess != null) {
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import platform.darwin.NSObject
|
|||||||
import platform.darwin.dispatch_async
|
import platform.darwin.dispatch_async
|
||||||
import platform.darwin.dispatch_get_main_queue
|
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 {
|
class IosBluetoothManager : BluetoothManager {
|
||||||
private val _isBluetoothEnabled = MutableStateFlow(false)
|
private val _isBluetoothEnabled = MutableStateFlow(false)
|
||||||
override val isBluetoothEnabled: StateFlow<Boolean> = _isBluetoothEnabled.asStateFlow()
|
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>
|
<string>The app requires Bluetooth to connect and manage Torque devices.</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
<string>The app requires Bluetooth to connect and manage Torque devices.</string>
|
<string>The app requires Bluetooth to connect and manage Torque devices.</string>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>bluetooth-central</string>
|
||||||
|
</array>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||