Compare commits
22 Commits
a956bc3564
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
| 682af406f5 | |||
| 0557622dc3 | |||
| 59eb3c7440 | |||
| d2f8d0ff06 | |||
| 3e1c5edc19 | |||
| 027116e18e | |||
| c8abfd94c3 | |||
| 0763c2e1df | |||
| 3ac39136c6 | |||
| 5e0f2f1bb7 | |||
| 3517414ec1 | |||
| 3b62743481 | |||
| 18bd21fba1 | |||
| 77a3b19b24 | |||
| 70cd547fb7 | |||
| efa93ab912 | |||
| d7f3bf386d | |||
| 7436599ad3 | |||
| ad13a7e958 | |||
| 0c949a9bda | |||
| 415b5ef0d8 | |||
| 80390b09ba |
10
.idea/deploymentTargetDropDown.xml
generated
Normal file
10
.idea/deploymentTargetDropDown.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetDropDown">
|
||||
<value>
|
||||
<entry key="app">
|
||||
<State />
|
||||
</entry>
|
||||
</value>
|
||||
</component>
|
||||
</project>
|
||||
5
.idea/gradle.xml
generated
5
.idea/gradle.xml
generated
@@ -4,16 +4,15 @@
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-17" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveExternalAnnotations" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
10
.idea/migrations.xml
generated
Normal file
10
.idea/migrations.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectMigrations">
|
||||
<option name="MigrateToGradleLocalJavaHome">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,7 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -63,7 +62,9 @@ dependencies {
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
|
||||
implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
|
||||
implementation("androidx.navigation:navigation-compose:2.6.0")
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.14.0")
|
||||
implementation("com.github.manalkaff:JetStick:1.2")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
|
||||
Binary file not shown.
@@ -1,13 +1,11 @@
|
||||
package com.helible.pilot
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
|
||||
@@ -3,18 +3,34 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Request legacy Bluetooth permissions on older devices. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="30"/>
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<!-- Request Bluetooth permissions for API level 31+ -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:minSdkVersion="31"
|
||||
android:usesPermissionFlags="neverForLocation" tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" android:minSdkVersion="31"
|
||||
android:usesPermissionFlags="neverForLocation" tools:targetApi="s" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:minSdkVersion="31"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_CONNECT"
|
||||
android:minSdkVersion="31"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
|
||||
<uses-feature android:name="android.hardware.bluetooth" android:required="true"/>
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth"
|
||||
android:required="true" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -24,12 +40,12 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Testblue"
|
||||
android:theme="@style/Theme.Main"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name="com.helible.pilot.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Testblue">
|
||||
android:theme="@style/Theme.Main">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
BIN
app/src/main/ic_helicopter-playstore.png
Normal file
BIN
app/src/main/ic_helicopter-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,70 @@
|
||||
import com.curiouscreature.kotlin.math.*
|
||||
|
||||
data class Camera(
|
||||
inline var position: Float3 = Float3(),
|
||||
inline var target: Float3 = Float3()
|
||||
)
|
||||
|
||||
class Mesh(val name: String, val triangles: Array<Mat3>) {
|
||||
var position = Float3()
|
||||
var rotation = Float3()
|
||||
}
|
||||
|
||||
val cube = Mesh(
|
||||
"Cube", arrayOf(
|
||||
|
||||
// SOUTH
|
||||
Mat3(Float3(-1.0f, -1.0f, -1.0f), Float3(-1.0f, 1.0f, -1.0f), Float3(1.0f, 1.0f, -1.0f)),
|
||||
Mat3(Float3(-1.0f, -1.0f, -1.0f), Float3(1.0f, 1.0f, -1.0f), Float3(1.0f, -1.0f, -1.0f)),
|
||||
|
||||
// EAST
|
||||
Mat3(Float3(1.0f, -1.0f, -1.0f), Float3(1.0f, 1.0f, -1.0f), Float3(1.0f, 1.0f, 1.0f)),
|
||||
Mat3(Float3(1.0f, -1.0f, -1.0f), Float3(1.0f, 1.0f, 1.0f), Float3(1.0f, -1.0f, 1.0f)),
|
||||
|
||||
// NORTH
|
||||
Mat3(Float3(1.0f, -1.0f, 1.0f), Float3(1.0f, 1.0f, 1.0f), Float3(-1.0f, 1.0f, 1.0f)),
|
||||
Mat3(Float3(1.0f, -1.0f, 1.0f), Float3(-1.0f, 1.0f, 1.0f), Float3(-1.0f, -1.0f, 1.0f)),
|
||||
|
||||
// WEST
|
||||
Mat3(Float3(-1.0f, -1.0f, 1.0f), Float3(-1.0f, 1.0f, 1.0f), Float3(-1.0f, 1.0f, -1.0f)),
|
||||
Mat3(Float3(-1.0f, -1.0f, 1.0f), Float3(-1.0f, 1.0f, -1.0f), Float3(-1.0f, -1.0f, -1.0f)),
|
||||
|
||||
// TOP
|
||||
Mat3(Float3(-1.0f, 1.0f, -1.0f), Float3(-1.0f, 1.0f, 1.0f), Float3(1.0f, 1.0f, 1.0f)),
|
||||
Mat3(Float3(-1.0f, 1.0f, -1.0f), Float3(1.0f, 1.0f, 1.0f), Float3(1.0f, 1.0f, -1.0f)),
|
||||
|
||||
// BOTTOM
|
||||
Mat3(Float3(1.0f, -1.0f, 1.0f), Float3(-1.0f, -1.0f, 1.0f), Float3(-1.0f, -1.0f, -1.0f)),
|
||||
Mat3(Float3(1.0f, -1.0f, 1.0f), Float3(-1.0f, -1.0f, -1.0f), Float3(1.0f, -1.0f, -1.0f)),
|
||||
)
|
||||
)
|
||||
|
||||
fun project(coord: Float3, transMat: Mat4): Float2 {
|
||||
val (x, y, _, _) = transMat * (Float4(coord, 0f))
|
||||
return Float2(x, y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Project triangle to 2d
|
||||
* this was a shot in the dark, might be a bug here. Looks ok
|
||||
*/
|
||||
fun Mat3.project(transMat: Mat4): List<Float2> {
|
||||
|
||||
val projected = transMat * Mat4(this.x, this.y, this.z)
|
||||
|
||||
return listOf(
|
||||
Float2(projected[0][0], projected[0][1]),
|
||||
Float2(projected[1][0], projected[1][1]),
|
||||
Float2(projected[2][0], projected[2][1]),
|
||||
)
|
||||
}
|
||||
|
||||
fun render3d(camera: Camera, mesh: Mesh): List<List<Float2>> {
|
||||
|
||||
val viewMatrix = lookAt(camera.position, camera.target)
|
||||
val projectionMatrix = perspective(0.78f, 1f, 0.01f, 20f)
|
||||
val worldMatrix = rotation(mesh.rotation) * translation(mesh.position)
|
||||
val transformMatrix = projectionMatrix * viewMatrix * worldMatrix
|
||||
return mesh.triangles.map { it.project(transformMatrix) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
/*
|
||||
* Copyright (C) 2017 Romain Guy
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.curiouscreature.kotlin.math
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
enum class MatrixColumn {
|
||||
X, Y, Z, W
|
||||
}
|
||||
|
||||
data class Mat2(
|
||||
var x: Float2 = Float2(x = 1.0f),
|
||||
var y: Float2 = Float2(y = 1.0f)) {
|
||||
constructor(m: Mat2) : this(m.x.copy(), m.y.copy())
|
||||
|
||||
companion object {
|
||||
fun of(vararg a: Float): Mat2 {
|
||||
require(a.size >= 4)
|
||||
return Mat2(
|
||||
Float2(a[0], a[2]),
|
||||
Float2(a[1], a[3])
|
||||
)
|
||||
}
|
||||
|
||||
fun identity() = Mat2()
|
||||
}
|
||||
|
||||
operator fun get(column: Int) = when(column) {
|
||||
0 -> x
|
||||
1 -> y
|
||||
else -> throw IllegalArgumentException("column must be in 0..1")
|
||||
}
|
||||
operator fun get(column: Int, row: Int) = get(column)[row]
|
||||
|
||||
operator fun get(column: MatrixColumn) = when(column) {
|
||||
MatrixColumn.X -> x
|
||||
MatrixColumn.Y -> y
|
||||
else -> throw IllegalArgumentException("column must be X or Y")
|
||||
}
|
||||
operator fun get(column: MatrixColumn, row: Int) = get(column)[row]
|
||||
|
||||
operator fun invoke(row: Int, column: Int) = get(column - 1)[row - 1]
|
||||
operator fun invoke(row: Int, column: Int, v: Float) = set(column - 1, row - 1, v)
|
||||
|
||||
operator fun set(column: Int, v: Float2) {
|
||||
this[column].xy = v
|
||||
}
|
||||
operator fun set(column: Int, row: Int, v: Float) {
|
||||
this[column][row] = v
|
||||
}
|
||||
|
||||
operator fun unaryMinus() = Mat2(-x, -y)
|
||||
operator fun inc(): Mat2 {
|
||||
x++
|
||||
y++
|
||||
return this
|
||||
}
|
||||
operator fun dec(): Mat2 {
|
||||
x--
|
||||
y--
|
||||
return this
|
||||
}
|
||||
|
||||
operator fun plus(v: Float) = Mat2(x + v, y + v)
|
||||
operator fun minus(v: Float) = Mat2(x - v, y - v)
|
||||
operator fun times(v: Float) = Mat2(x * v, y * v)
|
||||
operator fun div(v: Float) = Mat2(x / v, y / v)
|
||||
|
||||
operator fun times(m: Mat2): Mat2 {
|
||||
val t = transpose(this)
|
||||
return Mat2(
|
||||
Float2(dot(t.x, m.x), dot(t.y, m.x)),
|
||||
Float2(dot(t.x, m.y), dot(t.y, m.y))
|
||||
)
|
||||
}
|
||||
|
||||
operator fun times(v: Float2): Float2 {
|
||||
val t = transpose(this)
|
||||
return Float2(dot(t.x, v), dot(t.y, v))
|
||||
}
|
||||
|
||||
fun toFloatArray() = floatArrayOf(
|
||||
x.x, y.x,
|
||||
x.y, y.y
|
||||
)
|
||||
|
||||
override fun toString(): String {
|
||||
return """
|
||||
|${x.x} ${y.x}|
|
||||
|${x.y} ${y.y}|
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class Mat3(
|
||||
var x: Float3 = Float3(x = 1.0f),
|
||||
var y: Float3 = Float3(y = 1.0f),
|
||||
var z: Float3 = Float3(z = 1.0f)) {
|
||||
constructor(m: Mat3) : this(m.x.copy(), m.y.copy(), m.z.copy())
|
||||
|
||||
companion object {
|
||||
fun of(vararg a: Float): Mat3 {
|
||||
require(a.size >= 9)
|
||||
return Mat3(
|
||||
Float3(a[0], a[3], a[6]),
|
||||
Float3(a[1], a[4], a[7]),
|
||||
Float3(a[2], a[5], a[8])
|
||||
)
|
||||
}
|
||||
|
||||
fun identity() = Mat3()
|
||||
}
|
||||
|
||||
operator fun get(column: Int) = when(column) {
|
||||
0 -> x
|
||||
1 -> y
|
||||
2 -> z
|
||||
else -> throw IllegalArgumentException("column must be in 0..2")
|
||||
}
|
||||
operator fun get(column: Int, row: Int) = get(column)[row]
|
||||
|
||||
operator fun get(column: MatrixColumn) = when(column) {
|
||||
MatrixColumn.X -> x
|
||||
MatrixColumn.Y -> y
|
||||
MatrixColumn.Z -> z
|
||||
else -> throw IllegalArgumentException("column must be X, Y or Z")
|
||||
}
|
||||
operator fun get(column: MatrixColumn, row: Int) = get(column)[row]
|
||||
|
||||
operator fun invoke(row: Int, column: Int) = get(column - 1)[row - 1]
|
||||
operator fun invoke(row: Int, column: Int, v: Float) = set(column - 1, row - 1, v)
|
||||
|
||||
operator fun set(column: Int, v: Float3) {
|
||||
this[column].xyz = v
|
||||
}
|
||||
operator fun set(column: Int, row: Int, v: Float) {
|
||||
this[column][row] = v
|
||||
}
|
||||
|
||||
operator fun unaryMinus() = Mat3(-x, -y, -z)
|
||||
operator fun inc(): Mat3 {
|
||||
x++
|
||||
y++
|
||||
z++
|
||||
return this
|
||||
}
|
||||
operator fun dec(): Mat3 {
|
||||
x--
|
||||
y--
|
||||
z--
|
||||
return this
|
||||
}
|
||||
|
||||
operator fun plus(v: Float) = Mat3(x + v, y + v, z + v)
|
||||
operator fun minus(v: Float) = Mat3(x - v, y - v, z - v)
|
||||
operator fun times(v: Float) = Mat3(x * v, y * v, z * v)
|
||||
operator fun div(v: Float) = Mat3(x / v, y / v, z / v)
|
||||
|
||||
operator fun times(m: Mat3): Mat3 {
|
||||
val t = transpose(this)
|
||||
return Mat3(
|
||||
Float3(dot(t.x, m.x), dot(t.y, m.x), dot(t.z, m.x)),
|
||||
Float3(dot(t.x, m.y), dot(t.y, m.y), dot(t.z, m.y)),
|
||||
Float3(dot(t.x, m.z), dot(t.y, m.z), dot(t.z, m.z))
|
||||
)
|
||||
}
|
||||
|
||||
operator fun times(v: Float3): Float3 {
|
||||
val t = transpose(this)
|
||||
return Float3(dot(t.x, v), dot(t.y, v), dot(t.z, v))
|
||||
}
|
||||
|
||||
fun toFloatArray() = floatArrayOf(
|
||||
x.x, y.x, z.x,
|
||||
x.y, y.y, z.y,
|
||||
x.z, y.z, z.z
|
||||
)
|
||||
|
||||
override fun toString(): String {
|
||||
return """
|
||||
|${x.x} ${y.x} ${z.x}|
|
||||
|${x.y} ${y.y} ${z.y}|
|
||||
|${x.z} ${y.z} ${z.z}|
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
data class Mat4(
|
||||
var x: Float4 = Float4(x = 1.0f),
|
||||
var y: Float4 = Float4(y = 1.0f),
|
||||
var z: Float4 = Float4(z = 1.0f),
|
||||
var w: Float4 = Float4(w = 1.0f)) {
|
||||
constructor(right: Float3, up: Float3, forward: Float3, position: Float3 = Float3()) :
|
||||
this(Float4(right), Float4(up), Float4(forward), Float4(position, 1.0f))
|
||||
constructor(m: Mat4) : this(m.x.copy(), m.y.copy(), m.z.copy(), m.w.copy())
|
||||
|
||||
companion object {
|
||||
fun of(vararg a: Float): Mat4 {
|
||||
require(a.size >= 16)
|
||||
return Mat4(
|
||||
Float4(a[0], a[4], a[8], a[12]),
|
||||
Float4(a[1], a[5], a[9], a[13]),
|
||||
Float4(a[2], a[6], a[10], a[14]),
|
||||
Float4(a[3], a[7], a[11], a[15])
|
||||
)
|
||||
}
|
||||
|
||||
fun identity() = Mat4()
|
||||
}
|
||||
|
||||
inline var right: Float3
|
||||
get() = x.xyz
|
||||
set(value) {
|
||||
x.xyz = value
|
||||
}
|
||||
inline var up: Float3
|
||||
get() = y.xyz
|
||||
set(value) {
|
||||
y.xyz = value
|
||||
}
|
||||
inline var forward: Float3
|
||||
get() = z.xyz
|
||||
set(value) {
|
||||
z.xyz = value
|
||||
}
|
||||
inline var position: Float3
|
||||
get() = w.xyz
|
||||
set(value) {
|
||||
w.xyz = value
|
||||
}
|
||||
|
||||
inline val scale: Float3
|
||||
get() = Float3(length(x.xyz), length(y.xyz), length(z.xyz))
|
||||
inline val translation: Float3
|
||||
get() = w.xyz
|
||||
val rotation: Float3
|
||||
get() {
|
||||
val x = normalize(right)
|
||||
val y = normalize(up)
|
||||
val z = normalize(forward)
|
||||
|
||||
return when {
|
||||
z.y <= -1.0f -> Float3(degrees(-HALF_PI), 0.0f, degrees(atan2( x.z, y.z)))
|
||||
z.y >= 1.0f -> Float3(degrees( HALF_PI), 0.0f, degrees(atan2(-x.z, -y.z)))
|
||||
else -> Float3(
|
||||
degrees(-asin(z.y)), degrees(-atan2(z.x, z.z)), degrees(atan2( x.y, y.y)))
|
||||
}
|
||||
}
|
||||
|
||||
inline val upperLeft: Mat3
|
||||
get() = Mat3(x.xyz, y.xyz, z.xyz)
|
||||
|
||||
operator fun get(column: Int) = when(column) {
|
||||
0 -> x
|
||||
1 -> y
|
||||
2 -> z
|
||||
3 -> w
|
||||
else -> throw IllegalArgumentException("column must be in 0..3")
|
||||
}
|
||||
operator fun get(column: Int, row: Int) = get(column)[row]
|
||||
|
||||
operator fun get(column: MatrixColumn) = when(column) {
|
||||
MatrixColumn.X -> x
|
||||
MatrixColumn.Y -> y
|
||||
MatrixColumn.Z -> z
|
||||
MatrixColumn.W -> w
|
||||
}
|
||||
operator fun get(column: MatrixColumn, row: Int) = get(column)[row]
|
||||
|
||||
operator fun invoke(row: Int, column: Int) = get(column - 1)[row - 1]
|
||||
operator fun invoke(row: Int, column: Int, v: Float) = set(column - 1, row - 1, v)
|
||||
|
||||
operator fun set(column: Int, v: Float4) {
|
||||
this[column].xyzw = v
|
||||
}
|
||||
operator fun set(column: Int, row: Int, v: Float) {
|
||||
this[column][row] = v
|
||||
}
|
||||
|
||||
operator fun unaryMinus() = Mat4(-x, -y, -z, -w)
|
||||
operator fun inc(): Mat4 {
|
||||
x++
|
||||
y++
|
||||
z++
|
||||
w++
|
||||
return this
|
||||
}
|
||||
operator fun dec(): Mat4 {
|
||||
x--
|
||||
y--
|
||||
z--
|
||||
w--
|
||||
return this
|
||||
}
|
||||
|
||||
operator fun plus(v: Float) = Mat4(x + v, y + v, z + v, w + v)
|
||||
operator fun minus(v: Float) = Mat4(x - v, y - v, z - v, w - v)
|
||||
operator fun times(v: Float) = Mat4(x * v, y * v, z * v, w * v)
|
||||
operator fun div(v: Float) = Mat4(x / v, y / v, z / v, w / v)
|
||||
|
||||
operator fun times(m: Mat4): Mat4 {
|
||||
val t = transpose(this)
|
||||
return Mat4(
|
||||
Float4(dot(t.x, m.x), dot(t.y, m.x), dot(t.z, m.x), dot(t.w, m.x)),
|
||||
Float4(dot(t.x, m.y), dot(t.y, m.y), dot(t.z, m.y), dot(t.w, m.y)),
|
||||
Float4(dot(t.x, m.z), dot(t.y, m.z), dot(t.z, m.z), dot(t.w, m.z)),
|
||||
Float4(dot(t.x, m.w), dot(t.y, m.w), dot(t.z, m.w), dot(t.w, m.w))
|
||||
)
|
||||
}
|
||||
|
||||
operator fun times(v: Float4): Float4 {
|
||||
val t = transpose(this)
|
||||
return Float4(dot(t.x, v), dot(t.y, v), dot(t.z, v), dot(t.w, v))
|
||||
}
|
||||
|
||||
fun toFloatArray() = floatArrayOf(
|
||||
x.x, y.x, z.x, w.x,
|
||||
x.y, y.y, z.y, w.y,
|
||||
x.z, y.z, z.z, w.z,
|
||||
x.w, y.w, z.w, w.w
|
||||
)
|
||||
|
||||
override fun toString(): String {
|
||||
return """
|
||||
|${x.x} ${y.x} ${z.x} ${w.x}|
|
||||
|${x.y} ${y.y} ${z.y} ${w.y}|
|
||||
|${x.z} ${y.z} ${z.z} ${w.z}|
|
||||
|${x.w} ${y.w} ${z.w} ${w.w}|
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
fun transpose(m: Mat2) = Mat2(
|
||||
Float2(m.x.x, m.y.x),
|
||||
Float2(m.x.y, m.y.y)
|
||||
)
|
||||
|
||||
fun transpose(m: Mat3) = Mat3(
|
||||
Float3(m.x.x, m.y.x, m.z.x),
|
||||
Float3(m.x.y, m.y.y, m.z.y),
|
||||
Float3(m.x.z, m.y.z, m.z.z)
|
||||
)
|
||||
fun inverse(m: Mat3): Mat3 {
|
||||
val a = m.x.x
|
||||
val b = m.x.y
|
||||
val c = m.x.z
|
||||
val d = m.y.x
|
||||
val e = m.y.y
|
||||
val f = m.y.z
|
||||
val g = m.z.x
|
||||
val h = m.z.y
|
||||
val i = m.z.z
|
||||
|
||||
val A = e * i - f * h
|
||||
val B = f * g - d * i
|
||||
val C = d * h - e * g
|
||||
|
||||
val det = a * A + b * B + c * C
|
||||
|
||||
return Mat3.of(
|
||||
A / det, B / det, C / det,
|
||||
(c * h - b * i) / det, (a * i - c * g) / det, (b * g - a * h) / det,
|
||||
(b * f - c * e) / det, (c * d - a * f) / det, (a * e - b * d) / det
|
||||
)
|
||||
}
|
||||
|
||||
fun transpose(m: Mat4) = Mat4(
|
||||
Float4(m.x.x, m.y.x, m.z.x, m.w.x),
|
||||
Float4(m.x.y, m.y.y, m.z.y, m.w.y),
|
||||
Float4(m.x.z, m.y.z, m.z.z, m.w.z),
|
||||
Float4(m.x.w, m.y.w, m.z.w, m.w.w)
|
||||
)
|
||||
fun inverse(m: Mat4): Mat4 {
|
||||
val result = Mat4()
|
||||
|
||||
var pair0 = m.z.z * m.w.w
|
||||
var pair1 = m.w.z * m.z.w
|
||||
var pair2 = m.y.z * m.w.w
|
||||
var pair3 = m.w.z * m.y.w
|
||||
var pair4 = m.y.z * m.z.w
|
||||
var pair5 = m.z.z * m.y.w
|
||||
var pair6 = m.x.z * m.w.w
|
||||
var pair7 = m.w.z * m.x.w
|
||||
var pair8 = m.x.z * m.z.w
|
||||
var pair9 = m.z.z * m.x.w
|
||||
var pair10 = m.x.z * m.y.w
|
||||
var pair11 = m.y.z * m.x.w
|
||||
|
||||
result.x.x = pair0 * m.y.y + pair3 * m.z.y + pair4 * m.w.y
|
||||
result.x.x -= pair1 * m.y.y + pair2 * m.z.y + pair5 * m.w.y
|
||||
result.x.y = pair1 * m.x.y + pair6 * m.z.y + pair9 * m.w.y
|
||||
result.x.y -= pair0 * m.x.y + pair7 * m.z.y + pair8 * m.w.y
|
||||
result.x.z = pair2 * m.x.y + pair7 * m.y.y + pair10 * m.w.y
|
||||
result.x.z -= pair3 * m.x.y + pair6 * m.y.y + pair11 * m.w.y
|
||||
result.x.w = pair5 * m.x.y + pair8 * m.y.y + pair11 * m.z.y
|
||||
result.x.w -= pair4 * m.x.y + pair9 * m.y.y + pair10 * m.z.y
|
||||
result.y.x = pair1 * m.y.x + pair2 * m.z.x + pair5 * m.w.x
|
||||
result.y.x -= pair0 * m.y.x + pair3 * m.z.x + pair4 * m.w.x
|
||||
result.y.y = pair0 * m.x.x + pair7 * m.z.x + pair8 * m.w.x
|
||||
result.y.y -= pair1 * m.x.x + pair6 * m.z.x + pair9 * m.w.x
|
||||
result.y.z = pair3 * m.x.x + pair6 * m.y.x + pair11 * m.w.x
|
||||
result.y.z -= pair2 * m.x.x + pair7 * m.y.x + pair10 * m.w.x
|
||||
result.y.w = pair4 * m.x.x + pair9 * m.y.x + pair10 * m.z.x
|
||||
result.y.w -= pair5 * m.x.x + pair8 * m.y.x + pair11 * m.z.x
|
||||
|
||||
pair0 = m.z.x * m.w.y
|
||||
pair1 = m.w.x * m.z.y
|
||||
pair2 = m.y.x * m.w.y
|
||||
pair3 = m.w.x * m.y.y
|
||||
pair4 = m.y.x * m.z.y
|
||||
pair5 = m.z.x * m.y.y
|
||||
pair6 = m.x.x * m.w.y
|
||||
pair7 = m.w.x * m.x.y
|
||||
pair8 = m.x.x * m.z.y
|
||||
pair9 = m.z.x * m.x.y
|
||||
pair10 = m.x.x * m.y.y
|
||||
pair11 = m.y.x * m.x.y
|
||||
|
||||
result.z.x = pair0 * m.y.w + pair3 * m.z.w + pair4 * m.w.w
|
||||
result.z.x -= pair1 * m.y.w + pair2 * m.z.w + pair5 * m.w.w
|
||||
result.z.y = pair1 * m.x.w + pair6 * m.z.w + pair9 * m.w.w
|
||||
result.z.y -= pair0 * m.x.w + pair7 * m.z.w + pair8 * m.w.w
|
||||
result.z.z = pair2 * m.x.w + pair7 * m.y.w + pair10 * m.w.w
|
||||
result.z.z -= pair3 * m.x.w + pair6 * m.y.w + pair11 * m.w.w
|
||||
result.z.w = pair5 * m.x.w + pair8 * m.y.w + pair11 * m.z.w
|
||||
result.z.w -= pair4 * m.x.w + pair9 * m.y.w + pair10 * m.z.w
|
||||
result.w.x = pair2 * m.z.z + pair5 * m.w.z + pair1 * m.y.z
|
||||
result.w.x -= pair4 * m.w.z + pair0 * m.y.z + pair3 * m.z.z
|
||||
result.w.y = pair8 * m.w.z + pair0 * m.x.z + pair7 * m.z.z
|
||||
result.w.y -= pair6 * m.z.z + pair9 * m.w.z + pair1 * m.x.z
|
||||
result.w.z = pair6 * m.y.z + pair11 * m.w.z + pair3 * m.x.z
|
||||
result.w.z -= pair10 * m.w.z + pair2 * m.x.z + pair7 * m.y.z
|
||||
result.w.w = pair10 * m.z.z + pair4 * m.x.z + pair9 * m.y.z
|
||||
result.w.w -= pair8 * m.y.z + pair11 * m.z.z + pair5 * m.x.z
|
||||
|
||||
val determinant = m.x.x * result.x.x + m.y.x * result.x.y + m.z.x * result.x.z + m.w.x * result.x.w
|
||||
|
||||
return result / determinant
|
||||
}
|
||||
|
||||
fun scale(s: Float3) = Mat4(Float4(x = s.x), Float4(y = s.y), Float4(z = s.z))
|
||||
fun scale(m: Mat4) = scale(m.scale)
|
||||
|
||||
fun translation(t: Float3) = Mat4(w = Float4(t, 1.0f))
|
||||
fun translation(m: Mat4) = translation(m.translation)
|
||||
|
||||
fun rotation(m: Mat4) = Mat4(normalize(m.right), normalize(m.up), normalize(m.forward))
|
||||
fun rotation(d: Float3): Mat4 {
|
||||
val r = transform(d, ::radians)
|
||||
val c = transform(r, { x -> cos(x) })
|
||||
val s = transform(r, { x -> sin(x) })
|
||||
|
||||
return Mat4.of(
|
||||
c.y * c.z, -c.x * s.z + s.x * s.y * c.z, s.x * s.z + c.x * s.y * c.z, 0.0f,
|
||||
c.y * s.z, c.x * c.z + s.x * s.y * s.z, -s.x * c.z + c.x * s.y * s.z, 0.0f,
|
||||
-s.y , s.x * c.y , c.x * c.y , 0.0f,
|
||||
0.0f , 0.0f , 0.0f , 1.0f
|
||||
)
|
||||
}
|
||||
fun rotation(axis: Float3, angle: Float): Mat4 {
|
||||
val x = axis.x
|
||||
val y = axis.y
|
||||
val z = axis.z
|
||||
|
||||
val r = radians(angle)
|
||||
val c = cos(r)
|
||||
val s = sin(r)
|
||||
val d = 1.0f - c
|
||||
|
||||
return Mat4.of(
|
||||
x * x * d + c , x * y * d - z * s, x * z * d + y * s, 0.0f,
|
||||
y * x * d + z * s, y * y * d + c , y * z * d - x * s, 0.0f,
|
||||
z * x * d - y * s, z * y * d + x * s, z * z * d + c , 0.0f,
|
||||
0.0f , 0.0f , 0.0f , 1.0f
|
||||
)
|
||||
}
|
||||
|
||||
fun normal(m: Mat4) = scale(1.0f / Float3(length2(m.right), length2(m.up), length2(m.forward))) * m
|
||||
|
||||
fun lookAt(eye: Float3, target: Float3, up: Float3 = Float3(z = 1.0f)): Mat4 {
|
||||
return lookTowards(eye, target - eye, up)
|
||||
}
|
||||
|
||||
fun lookTowards(eye: Float3, forward: Float3, up: Float3 = Float3(z = 1.0f)): Mat4 {
|
||||
val f = normalize(forward)
|
||||
val r = normalize(f x up)
|
||||
val u = normalize(r x f)
|
||||
return Mat4(Float4(r), Float4(u), Float4(f), Float4(eye, 1.0f))
|
||||
}
|
||||
|
||||
fun perspective(fov: Float, ratio: Float, near: Float, far: Float): Mat4 {
|
||||
val t = 1.0f / tan(radians(fov) * 0.5f)
|
||||
val a = (far + near) / (far - near)
|
||||
val b = (2.0f * far * near) / (far - near)
|
||||
val c = t / ratio
|
||||
return Mat4(Float4(x = c), Float4(y = t), Float4(z = a, w = 1.0f), Float4(z = -b))
|
||||
}
|
||||
|
||||
fun ortho(l: Float, r: Float, b: Float, t: Float, n: Float, f: Float) = Mat4(
|
||||
Float4(x = 2.0f / (r - 1.0f)),
|
||||
Float4(y = 2.0f / (t - b)),
|
||||
Float4(z = -2.0f / (f - n)),
|
||||
Float4(-(r + l) / (r - l), -(t + b) / (t - b), -(f + n) / (f - n), 1.0f)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (C) 2017 Romain Guy
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.curiouscreature.kotlin.math
|
||||
|
||||
data class Ray(var origin: Float3 = Float3(), var direction: Float3)
|
||||
|
||||
fun pointAt(r: Ray, t: Float) = r.origin + r.direction * t
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2017 Romain Guy
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:Suppress("NOTHING_TO_INLINE")
|
||||
|
||||
package com.curiouscreature.kotlin.math
|
||||
|
||||
const val PI = 3.1415926536f
|
||||
const val HALF_PI = PI * 0.5f
|
||||
const val TWO_PI = PI * 2.0f
|
||||
const val FOUR_PI = PI * 4.0f
|
||||
const val INV_PI = 1.0f / PI
|
||||
const val INV_TWO_PI = INV_PI * 0.5f
|
||||
const val INV_FOUR_PI = INV_PI * 0.25f
|
||||
|
||||
inline fun clamp(x: Float, min: Float, max: Float)= if (x < min) min else (if (x > max) max else x)
|
||||
|
||||
inline fun saturate(x: Float) = clamp(x, 0.0f, 1.0f)
|
||||
|
||||
inline fun mix(a: Float, b: Float, x: Float) = a * (1.0f - x) + b * x
|
||||
|
||||
inline fun degrees(v: Float) = v * (180.0f * INV_PI)
|
||||
|
||||
inline fun radians(v: Float) = v * (PI / 180.0f)
|
||||
|
||||
inline fun fract(v: Float) = v % 1
|
||||
|
||||
inline fun sqr(v: Float) = v * v
|
||||
|
||||
//inline fun pow(x: Float, y: Float) = StrictMath.pow(x.toDouble(), y.toDouble()).toFloat()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,70 +0,0 @@
|
||||
package com.helible.pilot
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
sealed interface ConnectionResult {
|
||||
object ConnectionEstablished: ConnectionResult
|
||||
data class Error(val message: String) : ConnectionResult
|
||||
}
|
||||
|
||||
interface BluetoothController {
|
||||
val isConnected: StateFlow<Boolean>
|
||||
val errors: SharedFlow<String>
|
||||
|
||||
fun connectToDevice(device: Device?): Flow<ConnectionResult>
|
||||
fun closeConnection()
|
||||
}
|
||||
|
||||
class AndroidBluetoothController : BluetoothController {
|
||||
|
||||
private val _isConnected: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
override val isConnected: StateFlow<Boolean>
|
||||
get() = _isConnected.asStateFlow()
|
||||
|
||||
private val _errors = MutableSharedFlow<String>()
|
||||
override val errors: SharedFlow<String>
|
||||
get() = _errors.asSharedFlow()
|
||||
|
||||
private var currentClientSocket: BluetoothSocket? = null
|
||||
|
||||
companion object {
|
||||
const val SERVICE_UUID = "af7cc14b-cffa-4a3d-b677-01b0ff0a93d7"
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun connectToDevice(device: Device?): Flow<ConnectionResult> {
|
||||
return flow {
|
||||
currentClientSocket = device?.bluetoothDevice?.createRfcommSocketToServiceRecord(
|
||||
UUID.fromString(SERVICE_UUID)
|
||||
)
|
||||
currentClientSocket?.let { socket ->
|
||||
try {
|
||||
socket.connect()
|
||||
emit(ConnectionResult.ConnectionEstablished)
|
||||
} catch (e: IOException) {
|
||||
closeConnection()
|
||||
emit(ConnectionResult.Error("Connection was interrupted"))
|
||||
}
|
||||
}
|
||||
}.onCompletion { closeConnection() }.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
override fun closeConnection() {
|
||||
currentClientSocket?.close()
|
||||
currentClientSocket = null
|
||||
}
|
||||
}
|
||||
@@ -1,158 +1,122 @@
|
||||
package com.helible.pilot
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.helible.pilot.components.BluetoothScannerScreen
|
||||
import com.helible.pilot.components.PreferencesCacheImpl
|
||||
import com.helible.pilot.components.SavedPreferences
|
||||
import com.helible.pilot.components.CalibrationPage
|
||||
import com.helible.pilot.components.NotImplementedPage
|
||||
import com.helible.pilot.components.RotorsTestPage
|
||||
import com.helible.pilot.components.console.ConsolePage
|
||||
import com.helible.pilot.components.deviceScreen.DeviceControlScreen
|
||||
import com.helible.pilot.components.deviceScreen.defaultDeviceActionsList
|
||||
import com.helible.pilot.components.PidSettingsPage
|
||||
import com.helible.pilot.components.scannerScreen.ScannerScreen
|
||||
import com.helible.pilot.dataclasses.DeviceStatus
|
||||
import com.helible.pilot.permissions.PermissionsLauncher
|
||||
import com.helible.pilot.permissions.PermissionsRequest
|
||||
import com.helible.pilot.permissions.RequestHardwareFeatures
|
||||
import com.helible.pilot.ui.theme.TestblueTheme
|
||||
import java.util.concurrent.Executors
|
||||
import com.helible.pilot.viewmodels.AppPreferences
|
||||
import com.helible.pilot.viewmodels.BluetoothViewModel
|
||||
import com.helible.pilot.viewmodels.BluetoothViewModelFactory
|
||||
import com.helible.pilot.viewmodels.PermissionDialogViewModel
|
||||
import com.helible.pilot.viewmodels.PreferencesViewModel
|
||||
import com.helible.pilot.viewmodels.SavedPreferencesImpl
|
||||
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class MainActivity : ComponentActivity() {
|
||||
// TODO: delegate part of Intent filters logic to BluetoothController
|
||||
// TODO: move bluetooth states and stateFlow to new BluetoothViewModel
|
||||
// TODO: replace field bluetoothDevice in Device to deviceAddress field
|
||||
// TODO: replace some mutableStates to stateFlows
|
||||
// TODO: share selected device via PersistentViewModel
|
||||
// TODO: check permissions inside other classes (and throw an exception, if one of this isn't granted)
|
||||
// TODO: add stub instead of the DevicesList, if there aren't nearby devices
|
||||
// TODO: add Bluetooth data transfer...
|
||||
// TODO: add text strings to resource
|
||||
val mainViewModel: MainViewModel = MainViewModel(AndroidBluetoothController())
|
||||
// TODO: move text strings to resources
|
||||
|
||||
private val bluetoothManager: BluetoothManager by lazy {
|
||||
getSystemService(BluetoothManager::class.java)
|
||||
}
|
||||
private val bluetoothAdapter: BluetoothAdapter? by lazy {
|
||||
bluetoothManager.adapter
|
||||
}
|
||||
private var permissionsViewModel = PermissionDialogViewModel()
|
||||
private lateinit var permissionLauncher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>
|
||||
|
||||
private val permissionsToRequest: Array<String> by lazy {
|
||||
if (Build.VERSION.SDK_INT <= 30) {
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
)
|
||||
} else {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.BLUETOOTH_ADMIN
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val locationManager: LocationManager by lazy {
|
||||
getSystemService(LOCATION_SERVICE) as LocationManager
|
||||
}
|
||||
private val preferencesCache by lazy {
|
||||
PreferencesCacheImpl(getSharedPreferences(packageName, Context.MODE_PRIVATE))
|
||||
private val preferences by lazy {
|
||||
SavedPreferencesImpl(getSharedPreferences(packageName, MODE_PRIVATE))
|
||||
}
|
||||
private val preferencesViewModel by lazy {
|
||||
PersistentViewModel(preferencesCache)
|
||||
PreferencesViewModel(preferences)
|
||||
}
|
||||
|
||||
@ExperimentalStdlibApi
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
mainViewModel.bluetoothTurnOnState.value = bluetoothAdapter?.isEnabled
|
||||
mainViewModel.locationTurnOnState.value =
|
||||
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
|
||||
|
||||
setContent {
|
||||
this.permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
||||
onResult = { perms ->
|
||||
permissionsToRequest.forEach { permission ->
|
||||
permissionsViewModel.onPermissionResult(
|
||||
permission = permission,
|
||||
isGranted = perms[permission] == true
|
||||
)
|
||||
}
|
||||
}
|
||||
val bluetoothViewModel =
|
||||
viewModel<BluetoothViewModel>(factory = BluetoothViewModelFactory(applicationContext))
|
||||
|
||||
val permissionsViewModel = viewModel<PermissionDialogViewModel>()
|
||||
val permissionLauncher = PermissionsLauncher()
|
||||
permissionLauncher.setup(
|
||||
onPermissionResult = { perm, isGranted ->
|
||||
permissionsViewModel.onPermissionResult(perm, isGranted)
|
||||
},
|
||||
onGranted = { bluetoothViewModel.startScan() }
|
||||
)
|
||||
|
||||
val state by mainViewModel.state.collectAsState()
|
||||
val bluetoothState by bluetoothViewModel.state.collectAsState()
|
||||
val selectedDevice by bluetoothViewModel.selectedDevice.collectAsState()
|
||||
val rotorsDuty by bluetoothViewModel.rotorsDuty.collectAsState()
|
||||
|
||||
LaunchedEffect(key1 = state.errorMessage) {
|
||||
state.errorMessage?.let { message ->
|
||||
Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(key1 = state) {
|
||||
if (state.isConnected) {
|
||||
Toast.makeText(applicationContext, "Подключение завершено", Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
LaunchedEffect(key1 = null) {
|
||||
permissionLauncher.launch()
|
||||
}
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
LaunchedEffect(key1 = bluetoothState.errorMessage) {
|
||||
bluetoothState.errorMessage?.let { message ->
|
||||
Toast.makeText(applicationContext, "Ошибка: $message", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
TestblueTheme {
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
PermissionsRequest(
|
||||
dismissCurrentDialog = { permissionsViewModel.dismissDialog() },
|
||||
visiblePermissionDialogQueue = permissionsViewModel.visiblePermissionDialogQueue,
|
||||
activity = this,
|
||||
permissionLauncher = permissionLauncher
|
||||
permissionLaunch = { perms -> permissionLauncher.launch(perms) }
|
||||
)
|
||||
|
||||
RequestHardwareFeatures(
|
||||
activity = this,
|
||||
turnOnLocation = Manifest.permission.ACCESS_FINE_LOCATION in permissionsToRequest,
|
||||
bluetoothTurnOnState = mainViewModel.bluetoothTurnOnState,
|
||||
locationTurnOnState = mainViewModel.locationTurnOnState
|
||||
bluetoothUiState = bluetoothState
|
||||
)
|
||||
|
||||
NavHost(navController = navController, startDestination = "scanner") {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "device"
|
||||
) {
|
||||
composable("scanner") {
|
||||
BluetoothScannerScreen(
|
||||
devices = mainViewModel.devices,
|
||||
selectedDevice = mainViewModel.selectedDevice,
|
||||
bluetoothIsDiscoveringState = mainViewModel.isBluetoothDiscoveryRunning,
|
||||
bluetoothAdapter = bluetoothAdapter,
|
||||
ScannerScreen(
|
||||
bluetoothState = bluetoothState,
|
||||
selectedDevice = selectedDevice,
|
||||
startScan = { bluetoothViewModel.startScan() },
|
||||
cancelScan = { bluetoothViewModel.cancelScan() },
|
||||
choiceDevice = { device -> bluetoothViewModel.selectDevice(device) },
|
||||
onScreenChanged = {
|
||||
bluetoothAdapter?.cancelDiscovery()
|
||||
bluetoothViewModel.cancelScan()
|
||||
val device = selectedDevice
|
||||
if (device == null) {
|
||||
preferencesViewModel.clearPreferences()
|
||||
} else {
|
||||
preferencesViewModel.savePreferences(
|
||||
SavedPreferences(
|
||||
mainViewModel.selectedDevice.value?.bluetoothDevice?.address
|
||||
AppPreferences(
|
||||
deviceName = device.name,
|
||||
deviceAddress = device.macAddress
|
||||
)
|
||||
)
|
||||
navController.navigate("flight")
|
||||
}
|
||||
navController.navigate("device")
|
||||
Log.i(
|
||||
"ScanActivity",
|
||||
"Preferences: ${preferencesViewModel.preferences}"
|
||||
@@ -160,30 +124,117 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("flight") {
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// TODO: refactor
|
||||
val device: Device = mainViewModel.selectedDevice.value!!
|
||||
mainViewModel.connectToDevice(device)
|
||||
composable("device")
|
||||
{
|
||||
DeviceControlScreen(
|
||||
bluetoothUiState = bluetoothState,
|
||||
getPreferences = { preferencesViewModel.preferences },
|
||||
navigateToPage = { page -> navController.navigate(page) },
|
||||
connectToDevice = { device ->
|
||||
bluetoothViewModel.connectToDevice(
|
||||
device
|
||||
)
|
||||
},
|
||||
disconnectFromDevice = {
|
||||
preferencesViewModel.clearPreferences()
|
||||
bluetoothViewModel.disconnectFromDevice()
|
||||
},
|
||||
deviceActionsList = defaultDeviceActionsList()
|
||||
)
|
||||
if (preferencesViewModel.preferences != null) BackHandler {}
|
||||
}
|
||||
when {
|
||||
state.isConnecting -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(text = "Подключение...", textAlign = TextAlign.Center)
|
||||
composable("console/{title}")
|
||||
{ _ ->
|
||||
ConsolePage(
|
||||
startTakeoff = { bluetoothViewModel.startTakeoff() },
|
||||
startOnboarding = { bluetoothViewModel.startOnboarding() },
|
||||
stop = { bluetoothViewModel.stopRotors() },
|
||||
changeStick1Position = {
|
||||
_: Int, heightVelocity: Int ->
|
||||
bluetoothViewModel.changeHeightStickPosition(heightVelocity)
|
||||
},
|
||||
changeStick2Position = {
|
||||
x: Int, y: Int ->
|
||||
bluetoothViewModel.changePitchStickPosition(y)
|
||||
bluetoothViewModel.changeYawStickPosition(x)
|
||||
},
|
||||
bluetoothUiState = bluetoothState,
|
||||
reconnect = {
|
||||
val preferences = preferences.getPreferences()
|
||||
if(preferences != null) {
|
||||
bluetoothViewModel.connectToDevice(
|
||||
preferences.deviceAddress
|
||||
)
|
||||
} else {
|
||||
navController.navigate("scanner")
|
||||
}
|
||||
},
|
||||
navigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
BackHandler {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
text = "Device name: ${mainViewModel.selectedDevice.value?.bluetoothDevice?.name}",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
textAlign = TextAlign.Center
|
||||
composable("codeblocks/{title}")
|
||||
{ backStackEntry ->
|
||||
NotImplementedPage(
|
||||
title = backStackEntry.arguments?.getString("title") ?: "null",
|
||||
navigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable("imu_calibration/{title}")
|
||||
{ backStackEntry ->
|
||||
CalibrationPage(
|
||||
deviceStatus = bluetoothState.deviceState?.status,
|
||||
startCalibration = { bluetoothViewModel.startImuCalibration() },
|
||||
title = backStackEntry.arguments?.getString("title") ?: "null",
|
||||
navigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable("motor_test/{title}")
|
||||
{ backStackEntry ->
|
||||
RotorsTestPage(
|
||||
title = backStackEntry.arguments?.getString("title") ?: "null",
|
||||
rotorsDuty = rotorsDuty,
|
||||
setRotorsDuty = { bluetoothViewModel.setRotorsDuty(it) },
|
||||
startTelemetrySending = { bluetoothViewModel.startRotorsConfigurationTelemetry() },
|
||||
stopRotors = { bluetoothViewModel.stopRotors() },
|
||||
navigateBack = {
|
||||
navController.popBackStack()
|
||||
bluetoothViewModel.stopRotorsConfigurationTelemetry()
|
||||
bluetoothViewModel.stopRotors()
|
||||
}
|
||||
)
|
||||
BackHandler {
|
||||
navController.popBackStack()
|
||||
bluetoothViewModel.stopRotorsConfigurationTelemetry()
|
||||
bluetoothViewModel.stopRotors()
|
||||
}
|
||||
}
|
||||
composable("pid_settings/{title}")
|
||||
{ backStackEntry ->
|
||||
PidSettingsPage(
|
||||
title = backStackEntry.arguments?.getString("title") ?: "null",
|
||||
navigateBack = {
|
||||
navController.popBackStack()
|
||||
bluetoothViewModel.clearPidSettings()
|
||||
},
|
||||
requestPidSettings = { bluetoothViewModel.requestPidSettings() },
|
||||
setPidSettings = {settings -> bluetoothViewModel.applyPidSettings(settings)},
|
||||
deviceState = bluetoothState.deviceState
|
||||
)
|
||||
BackHandler {
|
||||
navController.popBackStack()
|
||||
bluetoothViewModel.clearPidSettings()
|
||||
}
|
||||
}
|
||||
composable("reports/{title}")
|
||||
{ backStackEntry ->
|
||||
NotImplementedPage(
|
||||
title = backStackEntry.arguments?.getString("title") ?: "null",
|
||||
navigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -191,59 +242,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
registerIntentFilters(this, receiver)
|
||||
requestPermissions()
|
||||
}
|
||||
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent) {
|
||||
receiveIntentChanges(
|
||||
intent,
|
||||
mainViewModel,
|
||||
bluetoothAdapter,
|
||||
locationManager
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestPermissions() {
|
||||
Executors.newSingleThreadExecutor().execute {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
permissionLauncher.launch(permissionsToRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterReceiver(receiver)
|
||||
super.onDestroy()
|
||||
Log.i("ScanActivity", "ACTIVITY DESTROYED")
|
||||
bluetoothAdapter?.cancelDiscovery()
|
||||
try {
|
||||
unregisterReceiver(receiver)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(
|
||||
"ScanActivity",
|
||||
"Receiver wasn't registered ${e.localizedMessage}\nStackTrace: ${e.stackTrace}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (bluetoothAdapter?.isDiscovering != true)
|
||||
bluetoothAdapter?.startDiscovery()
|
||||
Log.i("ScanActivity", "ACTIVITY STARTED")
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
bluetoothAdapter?.cancelDiscovery()
|
||||
mainViewModel.devices.clear()
|
||||
mainViewModel.selectedDevice.value = null
|
||||
Log.i("ScanActivity", "ACTIVITY STOPPED")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
package com.helible.pilot
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.helible.pilot.components.SavedPreferences
|
||||
import com.helible.pilot.components.SavedPreferencesCache
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class Device(
|
||||
val bluetoothDevice: BluetoothDevice,
|
||||
val rssi: Short,
|
||||
)
|
||||
|
||||
data class BluetoothUiState(
|
||||
val isConnected: Boolean = false,
|
||||
val isConnecting: Boolean = false,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
class MainViewModel(
|
||||
private val bluetoothController: BluetoothController
|
||||
) : ViewModel() {
|
||||
val devices: MutableList<Device> = mutableStateListOf()
|
||||
val selectedDevice: MutableState<Device?> = mutableStateOf(null)
|
||||
val bluetoothTurnOnState: MutableState<Boolean?> = mutableStateOf(false)
|
||||
val locationTurnOnState: MutableState<Boolean?> = mutableStateOf(null)
|
||||
val isBluetoothDiscoveryRunning: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
private val _bluetoothState = MutableStateFlow(BluetoothUiState())
|
||||
val state: StateFlow<BluetoothUiState>
|
||||
get() = _bluetoothState.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), _bluetoothState.value)
|
||||
|
||||
init {
|
||||
bluetoothController.isConnected.onEach {
|
||||
isConnected -> _bluetoothState.update { it.copy(isConnected = isConnected) }
|
||||
}.launchIn(viewModelScope)
|
||||
bluetoothController.errors.onEach { error ->
|
||||
_bluetoothState.update {
|
||||
it.copy(errorMessage = error)
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun Flow<ConnectionResult>.listen(): Job {
|
||||
return onEach { result ->
|
||||
when(result) {
|
||||
ConnectionResult.ConnectionEstablished -> {
|
||||
_bluetoothState.update {
|
||||
it.copy(
|
||||
isConnected = true,
|
||||
isConnecting = false,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
}
|
||||
is ConnectionResult.Error -> {
|
||||
_bluetoothState.update { it.copy(
|
||||
isConnected = false,
|
||||
isConnecting = false,
|
||||
errorMessage = result.message
|
||||
) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.catch { throwable ->
|
||||
bluetoothController.closeConnection()
|
||||
_bluetoothState.update {
|
||||
it.copy(
|
||||
isConnected = false,
|
||||
isConnecting = false
|
||||
)
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
private var deviceConnectionJob: Job? = null
|
||||
|
||||
fun connectToDevice(device: Device) {
|
||||
_bluetoothState.update {it.copy(isConnecting = true)}
|
||||
deviceConnectionJob = bluetoothController
|
||||
.connectToDevice(device)
|
||||
.listen()
|
||||
}
|
||||
|
||||
fun disconnectFromDevice() {
|
||||
deviceConnectionJob?.cancel()
|
||||
bluetoothController.closeConnection()
|
||||
_bluetoothState.update {
|
||||
it.copy(
|
||||
isConnecting = false,
|
||||
isConnected = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionDialogViewModel: ViewModel() {
|
||||
val visiblePermissionDialogQueue = mutableStateListOf<String>()
|
||||
|
||||
fun dismissDialog() {
|
||||
visiblePermissionDialogQueue.removeFirst()
|
||||
}
|
||||
|
||||
fun onPermissionResult(permission: String, isGranted: Boolean) {
|
||||
if(!isGranted && !visiblePermissionDialogQueue.contains(permission)){
|
||||
visiblePermissionDialogQueue.add(permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PersistentViewModel(
|
||||
private val preferencesCache: SavedPreferencesCache,
|
||||
) : ViewModel() {
|
||||
val preferences get() = preferencesCache.getPreferences()
|
||||
fun savePreferences(savedPreferences: SavedPreferences) {
|
||||
preferencesCache.savePreferences(
|
||||
preferences = savedPreferences
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package com.helible.pilot
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
|
||||
|
||||
fun registerIntentFilters(activity: Activity, receiver: BroadcastReceiver) {
|
||||
activity.registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_FOUND))
|
||||
activity.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
|
||||
activity.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_STARTED))
|
||||
activity.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED))
|
||||
if (Build.VERSION.SDK_INT <= 30)
|
||||
activity.registerReceiver(receiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION))
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun receiveIntentChanges(
|
||||
intent: Intent,
|
||||
mainViewModel: MainViewModel,
|
||||
bluetoothAdapter: BluetoothAdapter?,
|
||||
locationManager: LocationManager,
|
||||
) {
|
||||
when (intent.action) {
|
||||
BluetoothDevice.ACTION_FOUND -> {
|
||||
val device = if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getParcelableExtra(
|
||||
BluetoothDevice.EXTRA_DEVICE,
|
||||
BluetoothDevice::class.java
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
|
||||
}
|
||||
|
||||
val rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE)
|
||||
if (device?.name != null)
|
||||
mainViewModel.devices.add(Device(device, rssi))
|
||||
Log.i(
|
||||
"ScanActivity",
|
||||
"Found new device: ${device?.name} ${device?.address} $rssi"
|
||||
)
|
||||
}
|
||||
|
||||
BluetoothAdapter.ACTION_STATE_CHANGED -> {
|
||||
val state: Int = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)
|
||||
Log.i("ScanActvity", "Bluetooth state: $state")
|
||||
when (state) {
|
||||
BluetoothAdapter.STATE_ON -> {
|
||||
Log.i("ScanActvity", "Bluetooth turned on")
|
||||
mainViewModel.bluetoothTurnOnState.value = true
|
||||
mainViewModel.devices.clear()
|
||||
bluetoothAdapter?.startDiscovery()
|
||||
}
|
||||
|
||||
BluetoothAdapter.STATE_OFF -> {
|
||||
Log.i("ScanActvity", "Bluetooth turned off")
|
||||
mainViewModel.bluetoothTurnOnState.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
LocationManager.PROVIDERS_CHANGED_ACTION -> {
|
||||
mainViewModel.locationTurnOnState.value = locationManager.isLocationEnabled
|
||||
if (mainViewModel.locationTurnOnState.value == true) {
|
||||
Log.i("ScanActivity", "LOCATION IS ON")
|
||||
} else if (mainViewModel.locationTurnOnState.value == false)
|
||||
Log.i("ScanActivity", "LOCATION IS OFF")
|
||||
}
|
||||
|
||||
BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> {
|
||||
mainViewModel.isBluetoothDiscoveryRunning.value = false
|
||||
Log.i("ScanActivity", "DISCOVERY FINISHED")
|
||||
}
|
||||
|
||||
BluetoothAdapter.ACTION_DISCOVERY_STARTED -> {
|
||||
mainViewModel.isBluetoothDiscoveryRunning.value = true
|
||||
Log.i("ScanActivity", "DISCOVERY STARTED")
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/src/main/java/com/helible/pilot/components/BlankPage.kt
Normal file
59
app/src/main/java/com/helible/pilot/components/BlankPage.kt
Normal file
@@ -0,0 +1,59 @@
|
||||
package com.helible.pilot.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun BlankPage(title: String, navigateBack: () -> Unit, block: @Composable () -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
IconButton(onClick = { navigateBack() }) {
|
||||
Icon(
|
||||
Icons.Default.ArrowBack,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun BlankPagePreview() {
|
||||
Surface {
|
||||
BlankPage(title = "Blank page", navigateBack = {}) {
|
||||
Column(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp)) {
|
||||
Text(text = "This page in development")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.helible.pilot.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.widget.Spinner
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.helible.pilot.R
|
||||
import com.helible.pilot.dataclasses.BluetoothUiState
|
||||
import com.helible.pilot.dataclasses.ChangedDeviceStatus
|
||||
import com.helible.pilot.dataclasses.DeviceState
|
||||
import com.helible.pilot.dataclasses.DeviceStatus
|
||||
|
||||
@Composable
|
||||
fun CalibrationPage(
|
||||
deviceStatus: DeviceStatus?,
|
||||
title: String,
|
||||
startCalibration: () -> Unit,
|
||||
navigateBack: () -> Unit
|
||||
) {
|
||||
BlankPage(title = title, navigateBack = navigateBack) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = LocalContext.current.getString(R.string.calibration_description),
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp)
|
||||
)
|
||||
Button(
|
||||
enabled = deviceStatus != DeviceStatus.IsImuCalibration,
|
||||
onClick = startCalibration,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
if(deviceStatus != DeviceStatus.IsImuCalibration) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.tune),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(3.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Начать калибровку"
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator ()
|
||||
Text(
|
||||
text = "Калибровка...",
|
||||
modifier = Modifier.padding(5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showSystemUi = true)
|
||||
@Composable
|
||||
fun CalibrationPagePreview() {
|
||||
Surface {
|
||||
CalibrationPage(
|
||||
DeviceStatus.IsImuCalibration,
|
||||
title = "Калибровка гироскопа и акселерометра",
|
||||
startCalibration = {},
|
||||
navigateBack = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package com.helible.pilot.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.text.HtmlCompat
|
||||
import com.helible.pilot.Device
|
||||
import com.helible.pilot.R
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@Composable
|
||||
fun DeviceItem(deviceInfo: Device, selectedDevice: MutableState<Device?>, modifier: Modifier) {
|
||||
ElevatedCard(
|
||||
modifier=modifier.clickable {
|
||||
selectedDevice.value = deviceInfo
|
||||
},
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = if (deviceInfo.bluetoothDevice == selectedDevice.value?.bluetoothDevice)
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Row(modifier=Modifier.padding(8.dp)) {
|
||||
Column(verticalArrangement = Arrangement.Center) {
|
||||
Text(text=deviceInfo.bluetoothDevice.name, fontWeight = FontWeight.Bold, softWrap = true)
|
||||
AndroidView(factory = { context ->
|
||||
TextView(context).apply {
|
||||
// Using old TextView for text formatting
|
||||
text = HtmlCompat.fromHtml("<b>MAC</b>: ${deviceInfo.bluetoothDevice.address}", HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
}
|
||||
})
|
||||
}
|
||||
Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.fillMaxSize()) {
|
||||
Icon(
|
||||
painterResource(id = getSignalIconForRssiValue(deviceInfo.rssi)),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(10.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSignalIconForRssiValue(rssi: Short): Int{
|
||||
if (rssi >= -80) return R.drawable.signal_icon4
|
||||
else if (rssi >= -90) return R.drawable.signal_icon3
|
||||
else if (rssi >= -100) return R.drawable.signal_icon2
|
||||
return R.drawable.signal_icon1
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.helible.pilot.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.helible.pilot.Device
|
||||
|
||||
@Composable
|
||||
fun DiscoveredDevicesList(devices: MutableList<Device>, selectedDevice: MutableState<Device?>, modifier: Modifier = Modifier) {
|
||||
LazyColumn(modifier = modifier) {
|
||||
items(devices) { device ->
|
||||
DeviceItem(
|
||||
deviceInfo = device,
|
||||
selectedDevice = selectedDevice,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
5.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,24 +6,24 @@ import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Composable
|
||||
fun RequiredHardwareFeatures(
|
||||
title: String,
|
||||
description: String,
|
||||
confirmButtonText: String,
|
||||
featureState: MutableState<Boolean?>,
|
||||
featureState: Boolean,
|
||||
requestFeature: () -> Unit,
|
||||
onDismissRequest: () -> Unit
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
if (featureState.value == false || featureState.value == null) {
|
||||
if (!featureState) {
|
||||
AlertDialog(
|
||||
confirmButton = {
|
||||
Divider()
|
||||
TextButton(onClick = requestFeature, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text=confirmButtonText)
|
||||
Text(text = confirmButtonText)
|
||||
}
|
||||
},
|
||||
onDismissRequest = onDismissRequest,
|
||||
@@ -32,7 +32,20 @@ fun RequiredHardwareFeatures(
|
||||
text = description
|
||||
)
|
||||
},
|
||||
title = { Text(text = title)}
|
||||
title = { Text(text = title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RequiredHardwareFeaturesPreview() {
|
||||
RequiredHardwareFeatures(
|
||||
title = "Turn on Bluetooth",
|
||||
description = "App requires Bluetooth turned on to continue",
|
||||
confirmButtonText = "Turn on",
|
||||
featureState = false,
|
||||
requestFeature = {},
|
||||
onDismissRequest = {}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.helible.pilot.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.helible.pilot.components.BlankPage
|
||||
|
||||
@Composable
|
||||
fun NotImplementedPage(title: String, navigateBack: () -> Unit) {
|
||||
BlankPage(title = title, navigateBack = navigateBack) {
|
||||
Column(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp)) {
|
||||
Text(text = "Эта страница пока не готова и находится на стадии разработки")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,14 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
@@ -23,7 +24,7 @@ fun PermissionDialog(
|
||||
onOkClick: () -> Unit,
|
||||
onContinueClick: () -> Unit,
|
||||
onGoToAppSettingsClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -54,36 +55,51 @@ fun PermissionDialog(
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {if(isPermanentDeclined)
|
||||
Box(modifier=Modifier.fillMaxWidth()){
|
||||
dismissButton = {
|
||||
if (isPermanentDeclined)
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Divider()
|
||||
Text(
|
||||
text = "Снова проверить наличие разрешения",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(paddingValues = PaddingValues(top=10.dp))
|
||||
.clickable {onContinueClick()},
|
||||
.padding(paddingValues = PaddingValues(top = 10.dp))
|
||||
.clickable { onContinueClick() },
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)}
|
||||
)
|
||||
}
|
||||
else
|
||||
Unit},
|
||||
Unit
|
||||
},
|
||||
text = {
|
||||
Text(text = permissionTextProvider.getDescription(
|
||||
Text(
|
||||
text = permissionTextProvider.getDescription(
|
||||
isPermanentDeclined = isPermanentDeclined
|
||||
))
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PermissionDialogPreview() {
|
||||
PermissionDialog(
|
||||
LocationPermissionTextProvider(),
|
||||
false,
|
||||
{}, {}, {}, {}
|
||||
)
|
||||
}
|
||||
|
||||
interface PermissionTextProvider {
|
||||
fun getDescription(isPermanentDeclined: Boolean): String
|
||||
}
|
||||
|
||||
class LocationPermissionTextProvider : PermissionTextProvider {
|
||||
override fun getDescription(isPermanentDeclined: Boolean): String {
|
||||
return if (isPermanentDeclined){
|
||||
return if (isPermanentDeclined) {
|
||||
"Похоже вы навсегда запретили приложению доступ к геолокации. " +
|
||||
"Вы можете зайти в настройки, чтобы выдать это разрешение."
|
||||
} else {
|
||||
@@ -95,7 +111,7 @@ class LocationPermissionTextProvider : PermissionTextProvider {
|
||||
|
||||
class BluetoothScanPermissionTextProvider : PermissionTextProvider {
|
||||
override fun getDescription(isPermanentDeclined: Boolean): String {
|
||||
return if (isPermanentDeclined){
|
||||
return if (isPermanentDeclined) {
|
||||
"Похоже вы навсегда запретили приложению доступ к сканированию по Bluetooth. " +
|
||||
"Вы можете зайти в настройки, чтобы выдать это разрешение."
|
||||
} else {
|
||||
@@ -107,7 +123,7 @@ class BluetoothScanPermissionTextProvider : PermissionTextProvider {
|
||||
|
||||
class BluetoothConnectPermissionTextProvider : PermissionTextProvider {
|
||||
override fun getDescription(isPermanentDeclined: Boolean): String {
|
||||
return if (isPermanentDeclined){
|
||||
return if (isPermanentDeclined) {
|
||||
"Похоже вы навсегда запретили приложению доступ к подключению по Bluetooth." +
|
||||
"Вы можете зайти в настройки, чтобы выдать это разрешение."
|
||||
} else {
|
||||
@@ -119,7 +135,7 @@ class BluetoothConnectPermissionTextProvider : PermissionTextProvider {
|
||||
|
||||
class BluetoothAdminPermissionTextProvider : PermissionTextProvider {
|
||||
override fun getDescription(isPermanentDeclined: Boolean): String {
|
||||
return if (isPermanentDeclined){
|
||||
return if (isPermanentDeclined) {
|
||||
"Похоже вы навсегда запретили приложению доступ к управлению настройками Bluetooth. " +
|
||||
"Вы можете зайти в настройки, чтобы выдать это разрешение."
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
package com.helible.pilot.components
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import com.helible.pilot.R
|
||||
import com.helible.pilot.dataclasses.DeviceState
|
||||
import com.helible.pilot.dataclasses.DeviceStatus
|
||||
import com.helible.pilot.dataclasses.PidParams
|
||||
import com.helible.pilot.dataclasses.PidSettings
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PidSettingsPage(
|
||||
title: String,
|
||||
navigateBack: () -> Unit,
|
||||
requestPidSettings: () -> Unit,
|
||||
setPidSettings: (PidSettings) -> Unit,
|
||||
deviceState: DeviceState?,
|
||||
) {
|
||||
BlankPage(title = title, navigateBack = navigateBack) {
|
||||
var pValue by remember { mutableStateOf("") }
|
||||
var iValue by remember { mutableStateOf("") }
|
||||
var dValue by remember { mutableStateOf("") }
|
||||
val dropdownMenuItems =
|
||||
listOf("Контроллер высоты", "Контроллер тангажа", "Контроллер рысканья")
|
||||
var selectedRegulator by remember { mutableStateOf(dropdownMenuItems[0]) }
|
||||
|
||||
LaunchedEffect(null) {
|
||||
requestPidSettings()
|
||||
}
|
||||
LaunchedEffect(deviceState?.pidSettings) {
|
||||
if (deviceState?.pidSettings != null) {
|
||||
val pidSettings = deviceState.pidSettings
|
||||
when (selectedRegulator) {
|
||||
dropdownMenuItems[0] -> {
|
||||
pidSettings.heightControllerParams.p.toString().also { pValue = it }
|
||||
pidSettings.heightControllerParams.i.toString().also { iValue = it }
|
||||
pidSettings.heightControllerParams.d.toString().also { dValue = it }
|
||||
}
|
||||
|
||||
dropdownMenuItems[1] -> {
|
||||
pidSettings.yawControllerParams.p.toString().also { pValue = it }
|
||||
pidSettings.yawControllerParams.i.toString().also { iValue = it }
|
||||
pidSettings.yawControllerParams.d.toString().also { dValue = it }
|
||||
}
|
||||
|
||||
dropdownMenuItems[2] -> {
|
||||
pidSettings.pitchControllerParams.p.toString().also { pValue = it }
|
||||
pidSettings.pitchControllerParams.i.toString().also { iValue = it }
|
||||
pidSettings.pitchControllerParams.d.toString().also { dValue = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
)
|
||||
{
|
||||
if (deviceState?.status != DeviceStatus.Idle) {
|
||||
Text(
|
||||
text = "Этот раздел доступен только с подключенным заряженным бездействующим устройством.",
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
} else if (deviceState.pidSettings == null) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(10.dp))
|
||||
Text(text = "Синхронизация...")
|
||||
} else {
|
||||
val pidSettings = deviceState.pidSettings
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp).padding(bottom = 10.dp)) {
|
||||
Text(
|
||||
"Рекомендации по настройке ПИД регуляторов",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
)
|
||||
Text(
|
||||
text = LocalContext.current.getString(R.string.p_pid_value_description),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = LocalContext.current.getString(R.string.i_pid_value_description),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = LocalContext.current.getString(R.string.d_pid_value_description),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedDropdownMenu(
|
||||
label = "ПИД регулятор",
|
||||
suggestions = dropdownMenuItems,
|
||||
onChange = { selected ->
|
||||
Log.i("BluetoothVM", selected)
|
||||
when (dropdownMenuItems.indexOf(selected)) {
|
||||
0 -> {
|
||||
selectedRegulator = dropdownMenuItems[0]
|
||||
pidSettings.heightControllerParams.p.toString().also { pValue = it }
|
||||
pidSettings.heightControllerParams.i.toString().also { iValue = it }
|
||||
pidSettings.heightControllerParams.d.toString().also { dValue = it }
|
||||
}
|
||||
|
||||
1 -> {
|
||||
selectedRegulator = dropdownMenuItems[1]
|
||||
pidSettings.yawControllerParams.p.toString().also { pValue = it }
|
||||
pidSettings.yawControllerParams.i.toString().also { iValue = it }
|
||||
pidSettings.yawControllerParams.d.toString().also { dValue = it }
|
||||
}
|
||||
|
||||
2 -> {
|
||||
selectedRegulator = dropdownMenuItems[2]
|
||||
pidSettings.pitchControllerParams.p.toString().also { pValue = it }
|
||||
pidSettings.pitchControllerParams.i.toString().also { iValue = it }
|
||||
pidSettings.pitchControllerParams.d.toString().also { dValue = it }
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = pValue,
|
||||
onValueChange = { pValue = it },
|
||||
label = { Text(text = "P") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = iValue,
|
||||
onValueChange = { iValue = it },
|
||||
label = { Text(text = "I") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = dValue,
|
||||
onValueChange = { dValue = it },
|
||||
label = { Text(text = "D") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
}
|
||||
val p = pValue
|
||||
val i = iValue
|
||||
val d = dValue
|
||||
Button(
|
||||
onClick = {
|
||||
when (dropdownMenuItems.indexOf(selectedRegulator)) {
|
||||
0 -> {
|
||||
val newPidSettings = pidSettings.copy(
|
||||
heightControllerParams = PidParams(
|
||||
p.toFloat(),
|
||||
i.toFloat(),
|
||||
d.toFloat()
|
||||
)
|
||||
)
|
||||
setPidSettings(newPidSettings)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
val newPidSettings = pidSettings.copy(
|
||||
yawControllerParams = PidParams(
|
||||
p.toFloat(),
|
||||
i.toFloat(),
|
||||
d.toFloat()
|
||||
)
|
||||
)
|
||||
setPidSettings(newPidSettings)
|
||||
}
|
||||
|
||||
2 -> {
|
||||
val newPidSettings = pidSettings.copy(
|
||||
pitchControllerParams = PidParams(
|
||||
p.toFloat(),
|
||||
i.toFloat(),
|
||||
d.toFloat()
|
||||
)
|
||||
)
|
||||
setPidSettings(newPidSettings)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = isValidValue(p) && isValidValue(i) && isValidValue(d)
|
||||
) {
|
||||
Text(text = "Применить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isValidValue(k: String): Boolean {
|
||||
return k.toFloatOrNull() != null && k.toFloat() >= 0f && k.toFloat() <= 15f
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PidSettingsPreview() {
|
||||
PidSettingsPage(
|
||||
title = "Настройки ПИД регуляторов",
|
||||
navigateBack = { },
|
||||
requestPidSettings = { },
|
||||
setPidSettings = {},
|
||||
deviceState = DeviceState(
|
||||
status = DeviceStatus.Idle,
|
||||
pidSettings = PidSettings(
|
||||
PidParams(1f, 1f, 1f),
|
||||
PidParams(1f, 1f, 1f),
|
||||
PidParams(1f, 1f, 1f)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun DropdownDemo() {
|
||||
OutlinedDropdownMenu(label = "", suggestions = listOf("A", "B"), onChange = {})
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OutlinedDropdownMenu(
|
||||
label: String,
|
||||
suggestions: List<String>,
|
||||
onChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var selectedText by remember { mutableStateOf(suggestions.first()) }
|
||||
var textfieldSize by remember { mutableStateOf(Size.Zero) }
|
||||
|
||||
val icon = if (expanded)
|
||||
Icons.Filled.KeyboardArrowUp
|
||||
else
|
||||
Icons.Filled.KeyboardArrowDown
|
||||
|
||||
|
||||
Column(modifier = modifier) {
|
||||
OutlinedTextField(
|
||||
value = selectedText,
|
||||
onValueChange = {
|
||||
selectedText = it
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
// This value is used to assign to the DropDown the same width
|
||||
textfieldSize = coordinates.size.toSize()
|
||||
},
|
||||
label = { Text(label) },
|
||||
trailingIcon = {
|
||||
Icon(icon, "contentDescription",
|
||||
Modifier.clickable { expanded = !expanded })
|
||||
}
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier
|
||||
.width(with(LocalDensity.current) { textfieldSize.width.toDp() })
|
||||
) {
|
||||
suggestions.forEach { label ->
|
||||
DropdownMenuItem(onClick = {
|
||||
selectedText = label
|
||||
expanded = false
|
||||
onChange(selectedText)
|
||||
},
|
||||
text = { Text(label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
119
app/src/main/java/com/helible/pilot/components/RotorsTestPage.kt
Normal file
119
app/src/main/java/com/helible/pilot/components/RotorsTestPage.kt
Normal file
@@ -0,0 +1,119 @@
|
||||
package com.helible.pilot.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.helible.pilot.dataclasses.RotorsDuty
|
||||
import com.helible.pilot.ui.theme.TestblueTheme
|
||||
|
||||
@Composable
|
||||
fun RotorsTestPage(
|
||||
title: String,
|
||||
rotorsDuty: RotorsDuty,
|
||||
setRotorsDuty: (duty: RotorsDuty) -> Unit,
|
||||
startTelemetrySending: () -> Unit,
|
||||
stopRotors: () -> Unit,
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
BlankPage(title = title, navigateBack = navigateBack) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
LaunchedEffect(null) {
|
||||
startTelemetrySending()
|
||||
}
|
||||
Text(
|
||||
text = "R1",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 10.dp)
|
||||
)
|
||||
Slider(
|
||||
value = rotorsDuty.r1.toFloat(),
|
||||
onValueChange = { setRotorsDuty(rotorsDuty.copy(r1 = it.toInt().toShort())) },
|
||||
valueRange = 0f..5000f,
|
||||
steps = 10
|
||||
)
|
||||
Text(
|
||||
text = "При перемещении слайдера вправо ротор 1 должен вращаться против часовой стрелки, если смотреть сверху.",
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "R2",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 10.dp)
|
||||
)
|
||||
Slider(
|
||||
value = rotorsDuty.r2.toFloat(),
|
||||
onValueChange = { setRotorsDuty(rotorsDuty.copy(r2 = it.toInt().toShort())) },
|
||||
valueRange = 0f..5000f,
|
||||
steps = 10
|
||||
)
|
||||
Text(
|
||||
text = "При отклонении слайдера вправо от центра ротор 1 должен вращаться по часовой стрелке, а при отклонении влево - против часовой, если смотреть сверху.",
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "R3",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 10.dp)
|
||||
)
|
||||
Slider(
|
||||
value = rotorsDuty.r3.toFloat(),
|
||||
onValueChange = { setRotorsDuty(rotorsDuty.copy(r3 = it.toInt().toShort())) },
|
||||
valueRange = -5000f..5000f,
|
||||
steps = 10
|
||||
)
|
||||
Text(
|
||||
text = "При перемещении слайдера вправо ротор 1 должен вращаться по часовой стрелке, если смотреть сверху.",
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = { stopRotors() },
|
||||
containerColor = Color(245, 47, 7),
|
||||
modifier = Modifier.padding(top = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "СТОП",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, showSystemUi = true)
|
||||
@Composable
|
||||
fun RotorsTestPagePreview() {
|
||||
TestblueTheme {
|
||||
RotorsTestPage(
|
||||
title = "Тестирование моторов",
|
||||
rotorsDuty = RotorsDuty(5, 5, 5),
|
||||
setRotorsDuty = { _ -> },
|
||||
startTelemetrySending = {},
|
||||
stopRotors = {},
|
||||
navigateBack = { /*TODO*/ }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.helible.pilot.components
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Parcelable
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
|
||||
@Parcelize
|
||||
data class SavedPreferences(
|
||||
val deviceAddress: String?
|
||||
): Parcelable
|
||||
|
||||
interface SavedPreferencesCache {
|
||||
fun getPreferences(): SavedPreferences?
|
||||
fun savePreferences(preferences: SavedPreferences)
|
||||
fun clearPreferences()
|
||||
}
|
||||
|
||||
class PreferencesCacheImpl(private val sharedPreferences: SharedPreferences) : SavedPreferencesCache {
|
||||
override fun getPreferences(): SavedPreferences? {
|
||||
val json = sharedPreferences.getString("preferences", null) ?: return null
|
||||
return Gson().fromJson(json, SavedPreferences::class.java)
|
||||
}
|
||||
override fun savePreferences(preferences: SavedPreferences)
|
||||
{
|
||||
sharedPreferences.edit()
|
||||
.putString("preferences", Gson().toJson(preferences))
|
||||
.apply()
|
||||
|
||||
}
|
||||
|
||||
override fun clearPreferences()
|
||||
{
|
||||
sharedPreferences.edit().remove("preferences").apply()
|
||||
}
|
||||
}
|
||||
63
app/src/main/java/com/helible/pilot/components/World.kt
Normal file
63
app/src/main/java/com/helible/pilot/components/World.kt
Normal file
@@ -0,0 +1,63 @@
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.curiouscreature.kotlin.math.Float3
|
||||
|
||||
|
||||
@Composable
|
||||
fun World(mesh: Mesh = cube, drawVertices: MutableState<Boolean>, camerax: MutableState<Float>) {
|
||||
val camera = Camera(
|
||||
position = Float3(20f, 110.1f, 110.0f),
|
||||
target = Float3(0.1f, camerax.value, 1.0f)
|
||||
)
|
||||
Text("Camera: ${camera.target.y}")
|
||||
val animatedProgress by rememberInfiniteTransition().animateFloat(
|
||||
initialValue = 0.01f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 10000, easing = LinearEasing),
|
||||
),
|
||||
)
|
||||
|
||||
Canvas(Modifier.size(50.dp, 50.dp)) {
|
||||
mesh.rotation = Float3(animatedProgress + 90, animatedProgress + 180, 0.01f)
|
||||
camera.target.y += 0.1f
|
||||
camera.target.x += 0.1f
|
||||
camera.target.z -= 0.1f
|
||||
val lines = render3d(camera, mesh)
|
||||
lines.forEach { (one, two, three) ->
|
||||
if (drawVertices.value) {
|
||||
drawCircle(color = Color.Cyan, radius = 5f, center = Offset(one.x, one.y))
|
||||
drawCircle(color = Color.Cyan, radius = 5f, center = Offset(two.x, two.y))
|
||||
drawCircle(color = Color.Cyan, radius = 5f, center = Offset(three.x, three.y))
|
||||
}
|
||||
drawLine(
|
||||
color = Color.Red,
|
||||
start = Offset(one.x, one.y),
|
||||
end = Offset(two.x, two.y)
|
||||
)
|
||||
drawLine(
|
||||
color = Color.Red,
|
||||
start = Offset(two.x, two.y),
|
||||
end = Offset(three.x, three.y)
|
||||
)
|
||||
drawLine(
|
||||
color = Color.Red,
|
||||
start = Offset(three.x, three.y),
|
||||
end = Offset(one.x, one.y)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.helible.pilot.components.console
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExitToApp
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.helible.pilot.R
|
||||
import com.helible.pilot.dataclasses.BluetoothUiState
|
||||
import com.helible.pilot.dataclasses.DeviceStatus
|
||||
import com.manalkaff.jetstick.JoyStick
|
||||
|
||||
@Composable
|
||||
fun ConsolePage(
|
||||
startTakeoff: () -> Unit,
|
||||
startOnboarding: () -> Unit,
|
||||
stop: () -> Unit,
|
||||
reconnect: () -> Unit,
|
||||
changeStick1Position: (x: Int, y: Int) -> Unit,
|
||||
changeStick2Position: (x: Int, y: Int) -> Unit,
|
||||
bluetoothUiState: BluetoothUiState,
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
val deviceState = bluetoothUiState.deviceState
|
||||
LockScreenOrientation(orientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
|
||||
LaunchedEffect(key1 = null) {
|
||||
Log.i("Console", "state: ${bluetoothUiState.deviceState}, isConnected: ${bluetoothUiState.isConnected}")
|
||||
}
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (bluetoothUiState.isConnected && deviceState != null) {
|
||||
Text("Высота полёта: ${deviceState.flightHeight / 100} м; ")
|
||||
Text("Рыскание: ${deviceState.yaw}°; ")
|
||||
Text("Тангаж: ${deviceState.pitch}°; ")
|
||||
Text("Крен: ${deviceState.roll}°; ")
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
|
||||
JoyStick(
|
||||
size = 200.dp,
|
||||
dotSize = 80.dp,
|
||||
backgroundImage = R.drawable.stick_background,
|
||||
dotImage = R.drawable.stick_dot,
|
||||
modifier = Modifier.padding(30.dp)
|
||||
) { x: Float, y: Float ->
|
||||
changeStick1Position(x.toInt(), y.toInt())
|
||||
Log.d("JoyStick", "$x, $y")
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (bluetoothUiState.isConnected && deviceState != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (deviceState.status == DeviceStatus.IsPreparingForTakeoff) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(7.dp))
|
||||
}
|
||||
Text(text = describeStatus(deviceState.status))
|
||||
}
|
||||
Text(text = "Заряд батареи: ${deviceState.batteryCharge}%")
|
||||
} else {
|
||||
Text(text = "Нет соединения с устройством")
|
||||
}
|
||||
|
||||
if (bluetoothUiState.isConnecting) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(5.dp))
|
||||
Text(text = "Подключение...")
|
||||
}
|
||||
} else if (bluetoothUiState.isConnected) {
|
||||
Button(
|
||||
onClick = startTakeoff,
|
||||
enabled = deviceState?.status == DeviceStatus.Idle
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.KeyboardArrowUp, contentDescription = null)
|
||||
Text(text = "Взлёт")
|
||||
}
|
||||
}
|
||||
Button(onClick = stop, colors = ButtonDefaults.buttonColors(Color.Red)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Warning, contentDescription = null)
|
||||
Text(text = "СТОП")
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = startOnboarding,
|
||||
enabled = deviceState?.status in listOf(
|
||||
DeviceStatus.IsFlying, DeviceStatus.IsPreparingForTakeoff
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.KeyboardArrowDown, contentDescription = null)
|
||||
Text(text = "Посадка")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(onClick = reconnect) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null)
|
||||
Text(text = "Попробовать ещё раз")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!bluetoothUiState.isEnabled || deviceState?.status in listOf(DeviceStatus.ChargeRequired, DeviceStatus.Idle, null)) {
|
||||
Button(
|
||||
onClick = navigateBack
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Default.ExitToApp, contentDescription = null)
|
||||
Text(text = "Выход")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JoyStick(
|
||||
size = 200.dp,
|
||||
dotSize = 80.dp,
|
||||
backgroundImage = R.drawable.stick_background,
|
||||
dotImage = R.drawable.stick_dot,
|
||||
) { x: Float, y: Float ->
|
||||
changeStick2Position(x.toInt(), y.toInt())
|
||||
Log.d("JoyStick", "$x, $y")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LockScreenOrientation(orientation: Int) {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(orientation) {
|
||||
val activity = context.findActivity() ?: return@DisposableEffect onDispose {}
|
||||
val originalOrientation = activity.requestedOrientation
|
||||
activity.requestedOrientation = orientation
|
||||
onDispose {
|
||||
// restore original orientation when view disappears
|
||||
activity.requestedOrientation = originalOrientation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun describeStatus(deviceState: DeviceStatus): String {
|
||||
return when (deviceState) {
|
||||
DeviceStatus.Idle -> {
|
||||
"Устройство заряжено, бездействует"
|
||||
}
|
||||
|
||||
DeviceStatus.IsFlying -> {
|
||||
"В полёте"
|
||||
}
|
||||
|
||||
DeviceStatus.ChargeRequired -> {
|
||||
"Устройство разряжено. Дальнейшие полёты невозможны"
|
||||
}
|
||||
|
||||
DeviceStatus.IsImuCalibration -> {
|
||||
"Калибровка гироскопа и акселерометра. Пожалуйста, не двигайте устройство."
|
||||
}
|
||||
|
||||
DeviceStatus.IsPreparingForTakeoff -> {
|
||||
"Подготовка ко взлёту..."
|
||||
}
|
||||
|
||||
DeviceStatus.IsBoarding -> {
|
||||
"Посадка"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, device = "spec:parent=pixel_5,orientation=landscape")
|
||||
@Composable
|
||||
fun ConsolePreview() {
|
||||
ConsolePage(
|
||||
startTakeoff = {},
|
||||
startOnboarding = {},
|
||||
stop = {},
|
||||
changeStick1Position = { _: Int, _: Int -> },
|
||||
changeStick2Position = { _: Int, _: Int -> },
|
||||
bluetoothUiState = BluetoothUiState().copy(
|
||||
isConnected = false,
|
||||
deviceState = null
|
||||
),
|
||||
reconnect = {},
|
||||
navigateBack = {}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.helible.pilot.components.deviceScreen
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.helible.pilot.R
|
||||
import com.helible.pilot.dataclasses.BluetoothUiState
|
||||
import com.helible.pilot.dataclasses.DeviceState
|
||||
import com.helible.pilot.dataclasses.DeviceStatus
|
||||
import com.helible.pilot.viewmodels.AppPreferences
|
||||
|
||||
@Composable
|
||||
fun DeviceBadge(
|
||||
bluetoothUiState: BluetoothUiState,
|
||||
tryToReconnect: () -> Unit,
|
||||
getPreferences: () -> AppPreferences?,
|
||||
) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 15.dp),
|
||||
shape = RoundedCornerShape(15)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 15.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier
|
||||
.size(60.dp)
|
||||
.graphicsLayer {
|
||||
clip = true
|
||||
shape = RoundedCornerShape(15)
|
||||
}
|
||||
.fillMaxSize()) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.helicopter),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
Text(
|
||||
text = getPreferences()?.deviceName ?: "null",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
DeviceConnectionStatus(bluetoothUiState)
|
||||
if(bluetoothUiState.isConnected) {
|
||||
val deviceStatus = bluetoothUiState.deviceState?.status
|
||||
if (deviceStatus != null) {
|
||||
Text(text = "Заряд батареи: ${bluetoothUiState.deviceState.batteryCharge}%")
|
||||
if (deviceStatus == DeviceStatus.ChargeRequired) {
|
||||
Text(text = "Аккумулятор разряжен", color = Color.Red)
|
||||
} else {
|
||||
Text(text = deviceStatus.description())
|
||||
}
|
||||
} else {
|
||||
Text(text = "Ожиданием рукопожатия...")
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.requiredSize(Icons.Default.Refresh.defaultWidth)
|
||||
.clickable { tryToReconnect() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DeviceBadgePreview() {
|
||||
DeviceBadge(
|
||||
bluetoothUiState = BluetoothUiState(
|
||||
isConnected = false,
|
||||
isConnecting = true,
|
||||
deviceState = DeviceState(
|
||||
status = DeviceStatus.Idle,
|
||||
batteryCharge = 50,
|
||||
)
|
||||
),
|
||||
tryToReconnect = {},
|
||||
getPreferences = { AppPreferences("Helicopter", "AA:BB:CC:FF:DD") }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.helible.pilot.components.deviceScreen
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.helible.pilot.R
|
||||
import com.helible.pilot.dataclasses.BluetoothUiState
|
||||
|
||||
@Composable
|
||||
fun DeviceConnectionStatus(bluetoothState: BluetoothUiState) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (bluetoothState.isConnected) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = Color(56, 200, 35),
|
||||
modifier = Modifier
|
||||
.requiredSize(Icons.Default.CheckCircle.defaultWidth)
|
||||
.padding(2.dp)
|
||||
)
|
||||
Text("На связи")
|
||||
} else if (bluetoothState.errorMessage != null) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.cancel),
|
||||
contentDescription = null,
|
||||
tint = Color(255, 24, 35),
|
||||
modifier = Modifier
|
||||
.requiredSize(R.drawable.cancel.dp)
|
||||
.padding(2.dp)
|
||||
)
|
||||
Text("Ошибка: ${bluetoothState.errorMessage}")
|
||||
} else if (bluetoothState.isConnecting) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.sync),
|
||||
contentDescription = null,
|
||||
tint = Color(40, 123, 207),
|
||||
modifier = Modifier
|
||||
.requiredSize(R.drawable.sync.dp)
|
||||
.padding(2.dp)
|
||||
)
|
||||
Text("Подключение...")
|
||||
} else {
|
||||
Text("Попытка подключения не удалась.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DeviceConnectionStatusPreview() {
|
||||
Surface {
|
||||
DeviceConnectionStatus(bluetoothState = BluetoothUiState(isConnecting = true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.helible.pilot.components.deviceScreen
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.helible.pilot.R
|
||||
import com.helible.pilot.components.scannerScreen.Title
|
||||
import com.helible.pilot.dataclasses.BluetoothUiState
|
||||
import com.helible.pilot.viewmodels.AppPreferences
|
||||
|
||||
@Composable
|
||||
fun DeviceControlScreen(
|
||||
bluetoothUiState: BluetoothUiState,
|
||||
getPreferences: () -> AppPreferences?,
|
||||
navigateToPage: (String) -> Unit,
|
||||
connectToDevice: (String) -> Unit,
|
||||
disconnectFromDevice: () -> Unit,
|
||||
deviceActionsList: Map<String, Array<Pair<String, Pair<Pair<Int, Color>, String>>>>,
|
||||
scannerPageName: String = "scanner",
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
val preferences = getPreferences()
|
||||
if (preferences == null) {
|
||||
navigateToPage(scannerPageName)
|
||||
} else {
|
||||
connectToDevice(preferences.deviceAddress)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = bluetoothUiState.isEnabled) {
|
||||
/* Trying to reconnect, when bluetooth is turned on */
|
||||
val preferences = getPreferences()
|
||||
if (preferences != null && bluetoothUiState.isEnabled)
|
||||
connectToDevice(preferences.deviceAddress)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = bluetoothUiState.isLocationEnabled) {
|
||||
/* Trying to reconnect, when location is turned on */
|
||||
val preferences = getPreferences()
|
||||
if (preferences != null && bluetoothUiState.isLocationEnabled)
|
||||
connectToDevice(preferences.deviceAddress)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(5.dp)
|
||||
) {
|
||||
Title(
|
||||
text = "Ваше устройство",
|
||||
modifier = Modifier.padding(vertical = 15.dp, horizontal = 10.dp)
|
||||
)
|
||||
DeviceBadge(
|
||||
bluetoothUiState = bluetoothUiState,
|
||||
tryToReconnect = {
|
||||
/* Trying to reconnect, when error occurred */
|
||||
val preferences = getPreferences()
|
||||
if (preferences != null)
|
||||
connectToDevice(preferences.deviceAddress)
|
||||
},
|
||||
getPreferences = getPreferences
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 3.dp)) {
|
||||
for (section in deviceActionsList) {
|
||||
Text(
|
||||
section.key,
|
||||
color = Color.Gray,
|
||||
fontWeight = FontWeight.Light,
|
||||
modifier = Modifier.padding(vertical = 15.dp, horizontal = 10.dp)
|
||||
)
|
||||
for (action in section.value) {
|
||||
TextButton(
|
||||
onClick = { navigateToPage(action.first + '/' + action.second.second) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = action.second.first.first),
|
||||
tint = action.second.first.second,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(25.dp)
|
||||
)
|
||||
Text(
|
||||
text = action.second.second,
|
||||
color = MaterialTheme.colorScheme.inverseSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = {
|
||||
disconnectFromDevice()
|
||||
navigateToPage(scannerPageName)
|
||||
}, modifier = Modifier.padding(vertical = 10.dp)) {
|
||||
Icon(painterResource(id = R.drawable.logout), contentDescription = null)
|
||||
Text(
|
||||
text = "Отвязать устройство",
|
||||
color = MaterialTheme.colorScheme.inverseSurface,
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DeviceControlScreenPreview() {
|
||||
Surface {
|
||||
DeviceControlScreen(
|
||||
bluetoothUiState = BluetoothUiState(isConnected = true),
|
||||
getPreferences = { AppPreferences("Helicopter", "AA:BB:CC:DD:FF") },
|
||||
navigateToPage = { /*TODO*/ },
|
||||
connectToDevice = {},
|
||||
disconnectFromDevice = { /*TODO*/ },
|
||||
deviceActionsList = defaultDeviceActionsList()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.helible.pilot.components.deviceScreen
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.helible.pilot.R
|
||||
|
||||
@Composable
|
||||
fun defaultDeviceActionsList(): Map<String, Array<Pair<String, Pair<Pair<Int, Color>, String>>>> {
|
||||
return mapOf(
|
||||
Pair(
|
||||
"Управление",
|
||||
arrayOf(
|
||||
Pair(
|
||||
"console",
|
||||
Pair(
|
||||
Pair(R.drawable.joystick, MaterialTheme.colorScheme.primary),
|
||||
"Пульт управления"
|
||||
)
|
||||
),
|
||||
Pair(
|
||||
"codeblocks",
|
||||
Pair(
|
||||
Pair(R.drawable.code_blocks, MaterialTheme.colorScheme.primary),
|
||||
"Палитра команд"
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
Pair(
|
||||
"Настройки",
|
||||
arrayOf(
|
||||
Pair(
|
||||
"imu_calibration",
|
||||
Pair(
|
||||
Pair(R.drawable.tune, MaterialTheme.colorScheme.primary),
|
||||
"Калибровка гироскопа и акселерометра"
|
||||
)
|
||||
),
|
||||
Pair(
|
||||
"motor_test",
|
||||
Pair(
|
||||
Pair(R.drawable.helicopter_icon, MaterialTheme.colorScheme.primary),
|
||||
"Тестирование двигателей"
|
||||
)
|
||||
),
|
||||
Pair(
|
||||
"pid_settings",
|
||||
Pair(
|
||||
Pair(R.drawable.controller_gen, MaterialTheme.colorScheme.primary),
|
||||
"Настройки ПИД регуляторов"
|
||||
)
|
||||
),
|
||||
Pair(
|
||||
"reports",
|
||||
Pair(
|
||||
Pair(R.drawable.construction, MaterialTheme.colorScheme.primary),
|
||||
"Отчеты о полётах"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.helible.pilot.components.scannerScreen
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.helible.pilot.R
|
||||
import com.helible.pilot.dataclasses.BluetoothDevice
|
||||
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@Composable
|
||||
fun DeviceItem(
|
||||
deviceInfo: BluetoothDevice,
|
||||
selectedDevice: BluetoothDevice?,
|
||||
choiceDevice: (device: BluetoothDevice?) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
ElevatedCard(
|
||||
modifier = modifier.clickable {
|
||||
choiceDevice(deviceInfo)
|
||||
},
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = if (deviceInfo == selectedDevice)
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Row(modifier = Modifier.padding(8.dp)) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f, true)
|
||||
) {
|
||||
Text(
|
||||
text = deviceInfo.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
softWrap = true
|
||||
)
|
||||
Text(
|
||||
text = "MAC: ${deviceInfo.macAddress}",
|
||||
fontWeight = FontWeight.Thin
|
||||
)
|
||||
}
|
||||
if (deviceInfo.isScanned) {
|
||||
val icon = getSignalIconForRssiValue(deviceInfo.rssi)
|
||||
Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.weight(0.3f)) {
|
||||
Icon(
|
||||
painterResource(id = icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(10.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSignalIconForRssiValue(rssi: Short): Int {
|
||||
if (rssi >= -80) return R.drawable.signal_icon4
|
||||
else if (rssi >= -90) return R.drawable.signal_icon3
|
||||
else if (rssi >= -100) return R.drawable.signal_icon2
|
||||
return R.drawable.signal_icon1
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DeviceItemPreview() {
|
||||
DeviceItem(
|
||||
BluetoothDevice("Helicopter", "AA:BB:CC:DD:FF", -90, true),
|
||||
null,
|
||||
{ _ -> },
|
||||
modifier = Modifier.size(500.dp, 60.dp)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.helible.pilot.components.scannerScreen
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.helible.pilot.dataclasses.BluetoothDevice
|
||||
import com.helible.pilot.dataclasses.BluetoothUiState
|
||||
|
||||
@Composable
|
||||
fun DiscoveredDevicesList(
|
||||
bluetoothState: BluetoothUiState,
|
||||
selectedDevice: BluetoothDevice?,
|
||||
choiceDevice: (device: BluetoothDevice?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier) {
|
||||
item {
|
||||
Text(
|
||||
text = "Ранее подключенные устройства",
|
||||
textAlign = TextAlign.Left,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
items(bluetoothState.pairedBluetoothDevices) { device ->
|
||||
DeviceItem(
|
||||
deviceInfo = device,
|
||||
selectedDevice = selectedDevice,
|
||||
choiceDevice = choiceDevice,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
5.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
if (bluetoothState.pairedBluetoothDevices.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "Нет элементов для отображения",
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = "Доступные устройства",
|
||||
textAlign = TextAlign.Left,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(bluetoothState.scannedBluetoothDevices) { device ->
|
||||
DeviceItem(
|
||||
deviceInfo = device,
|
||||
selectedDevice = selectedDevice,
|
||||
choiceDevice = choiceDevice,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
5.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
if (bluetoothState.scannedBluetoothDevices.isEmpty()) {
|
||||
if (bluetoothState.isDiscovering) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(text = "Поиск устройств", modifier = Modifier.padding(10.dp))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
Text(
|
||||
text = "Устройства поблизости не обнаружены",
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DiscoveredDevicesListPreview() {
|
||||
val state = BluetoothUiState(
|
||||
pairedBluetoothDevices = listOf(
|
||||
BluetoothDevice("My car", "AA:BB:CC:DD:FF", -70, false),
|
||||
BluetoothDevice("Speaker", "AA:BB:CC:DD:FF", -20, false),
|
||||
BluetoothDevice("My TV", "AA:BB:CC:DD:FF", 10, false),
|
||||
BluetoothDevice("My phone", "AA:BB:CC:DD:FF", -50, false),
|
||||
BluetoothDevice("Mi Band 6", "AA:BB:CC:DD:FF", -100, false),
|
||||
),
|
||||
scannedBluetoothDevices = listOf(
|
||||
BluetoothDevice("Watch", "AA:BB:CC:DD:FF", -10, true),
|
||||
BluetoothDevice("Mi Cleaner", "AA:BB:CC:DD:FF", -90, true),
|
||||
BluetoothDevice("My fridge", "AA:BB:CC:DD:FF", -100, true),
|
||||
BluetoothDevice("Unknown device", "AA:BB:CC:DD:FF", -130, true)
|
||||
)
|
||||
)
|
||||
Surface {
|
||||
DiscoveredDevicesList(bluetoothState = state, selectedDevice = null, choiceDevice = {})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
package com.helible.pilot.components
|
||||
package com.helible.pilot.components.scannerScreen
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -12,30 +11,31 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconToggleButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import androidx.constraintlayout.compose.Dimension
|
||||
import com.helible.pilot.Device
|
||||
import com.helible.pilot.dataclasses.BluetoothDevice
|
||||
import com.helible.pilot.dataclasses.BluetoothUiState
|
||||
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@Composable
|
||||
fun BluetoothScannerScreen(
|
||||
devices: MutableList<Device>,
|
||||
selectedDevice: MutableState<Device?>,
|
||||
bluetoothIsDiscoveringState: MutableState<Boolean>,
|
||||
bluetoothAdapter: BluetoothAdapter?,
|
||||
fun ScannerScreen(
|
||||
bluetoothState: BluetoothUiState,
|
||||
selectedDevice: BluetoothDevice?,
|
||||
startScan: () -> Unit,
|
||||
cancelScan: () -> Unit,
|
||||
choiceDevice: (device: BluetoothDevice?) -> Unit,
|
||||
onScreenChanged: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
@@ -44,7 +44,7 @@ fun BluetoothScannerScreen(
|
||||
val (title, devicesList, controls) = createRefs()
|
||||
|
||||
Title(
|
||||
text = "Поиск устройств",
|
||||
text = "Устройства поблизости",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp)
|
||||
@@ -52,8 +52,9 @@ fun BluetoothScannerScreen(
|
||||
)
|
||||
|
||||
DiscoveredDevicesList(
|
||||
devices = devices,
|
||||
bluetoothState = bluetoothState,
|
||||
selectedDevice = selectedDevice,
|
||||
choiceDevice = choiceDevice,
|
||||
modifier = Modifier
|
||||
.constrainAs(devicesList) {
|
||||
top.linkTo(title.bottom)
|
||||
@@ -62,14 +63,6 @@ fun BluetoothScannerScreen(
|
||||
}
|
||||
)
|
||||
|
||||
if (devices.isEmpty() && bluetoothIsDiscoveringState.value) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(5.dp)
|
||||
@@ -81,21 +74,20 @@ fun BluetoothScannerScreen(
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
FilledIconToggleButton(
|
||||
checked = bluetoothIsDiscoveringState.value,
|
||||
checked = bluetoothState.isDiscovering,
|
||||
onCheckedChange = {
|
||||
selectedDevice.value = null
|
||||
if (bluetoothIsDiscoveringState.value)
|
||||
bluetoothAdapter?.cancelDiscovery()
|
||||
else {
|
||||
devices.clear()
|
||||
bluetoothAdapter?.startDiscovery()
|
||||
if (bluetoothState.isDiscovering) {
|
||||
cancelScan()
|
||||
Log.i("ScanActivity", "Trying to start scan via button")
|
||||
} else {
|
||||
startScan()
|
||||
}
|
||||
}, modifier = Modifier
|
||||
.align(Alignment.Bottom)
|
||||
.padding(5.dp)
|
||||
) {
|
||||
Icon(
|
||||
if (bluetoothIsDiscoveringState.value) Icons.Filled.Close
|
||||
if (bluetoothState.isDiscovering) Icons.Filled.Close
|
||||
else Icons.Filled.Refresh,
|
||||
contentDescription = null
|
||||
)
|
||||
@@ -107,12 +99,40 @@ fun BluetoothScannerScreen(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Bottom)
|
||||
.padding(5.dp),
|
||||
enabled = selectedDevice.value != null,
|
||||
enabled = selectedDevice != null,
|
||||
) {
|
||||
Text(text = "Далее")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ScannerScreenPreview() {
|
||||
val state = BluetoothUiState(
|
||||
pairedBluetoothDevices = listOf(
|
||||
BluetoothDevice("My car", "AA:BB:CC:DD:FF", -70, false),
|
||||
BluetoothDevice("Speaker", "AA:BB:CC:DD:FF", -20, false),
|
||||
BluetoothDevice("My TV", "AA:BB:CC:DD:FF", 10, false),
|
||||
BluetoothDevice("My phone", "AA:BB:CC:DD:FF", -50, false),
|
||||
BluetoothDevice("Mi Band 6", "AA:BB:CC:DD:FF", -100, false),
|
||||
),
|
||||
scannedBluetoothDevices = listOf(
|
||||
BluetoothDevice("Watch", "AA:BB:CC:DD:FF", -10, true),
|
||||
BluetoothDevice("Mi Cleaner", "AA:BB:CC:DD:FF", -90, true),
|
||||
BluetoothDevice("My fridge", "AA:BB:CC:DD:FF", -100, true),
|
||||
BluetoothDevice("Unknown device", "AA:BB:CC:DD:FF", -130, true)
|
||||
)
|
||||
)
|
||||
Surface {
|
||||
ScannerScreen(
|
||||
state,
|
||||
state.scannedBluetoothDevices[1],
|
||||
{}, {},
|
||||
{ _ -> },
|
||||
{},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
package com.helible.pilot.components
|
||||
package com.helible.pilot.components.scannerScreen
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun Title(text: String, modifier: Modifier = Modifier) {
|
||||
fun Title(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
fontSize: TextUnit = 23.sp,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = modifier,
|
||||
fontSize = 23.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontSize = fontSize,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package com.helible.pilot.controllers
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import com.helible.pilot.viewmodels.BluetoothDataTransferService
|
||||
import com.helible.pilot.dataclasses.BluetoothDeviceDomain
|
||||
import com.helible.pilot.dataclasses.GeneralMessage
|
||||
import com.helible.pilot.receivers.BluetoothAdapterStateReceiver
|
||||
import com.helible.pilot.receivers.BluetoothStateReceiver
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
sealed interface ConnectionResult {
|
||||
object ConnectionEstablished : ConnectionResult
|
||||
data class TransferSucceded(val message: GeneralMessage) : ConnectionResult
|
||||
data class Error(val message: String) : ConnectionResult
|
||||
}
|
||||
|
||||
interface BluetoothController {
|
||||
val isEnabled: StateFlow<Boolean>
|
||||
val isLocationEnabled: StateFlow<Boolean>
|
||||
val isConnected: StateFlow<Boolean>
|
||||
val isScanning: StateFlow<Boolean>
|
||||
val scannedDevices: StateFlow<List<BluetoothDeviceDomain>>
|
||||
val pairedDevices: StateFlow<List<BluetoothDeviceDomain>>
|
||||
val errors: SharedFlow<String>
|
||||
|
||||
fun startDiscovery()
|
||||
fun cancelDiscovery()
|
||||
fun connectToDevice(device: String): Flow<ConnectionResult>
|
||||
suspend fun trySendMessage(message: ByteArray): Boolean
|
||||
fun closeConnection()
|
||||
fun onDestroy()
|
||||
}
|
||||
|
||||
@ExperimentalStdlibApi
|
||||
class AndroidBluetoothController(private val context: Context) : BluetoothController {
|
||||
|
||||
private val bluetoothManager by lazy {
|
||||
context.getSystemService(BluetoothManager::class.java)
|
||||
}
|
||||
|
||||
private val bluetoothAdapter by lazy {
|
||||
bluetoothManager.adapter
|
||||
}
|
||||
|
||||
private val locationManager: LocationManager? by lazy {
|
||||
context.getSystemService(ComponentActivity.LOCATION_SERVICE) as LocationManager
|
||||
}
|
||||
|
||||
private var dataTransferService: BluetoothDataTransferService? = null
|
||||
|
||||
private val _isConnected: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
override val isConnected: StateFlow<Boolean>
|
||||
get() = _isConnected.asStateFlow()
|
||||
|
||||
private val _errors = MutableSharedFlow<String>()
|
||||
override val errors: SharedFlow<String>
|
||||
get() = _errors.asSharedFlow()
|
||||
|
||||
private val _isScanning: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
override val isScanning: StateFlow<Boolean>
|
||||
get() = _isScanning.asStateFlow()
|
||||
|
||||
private val _isEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
override val isEnabled: StateFlow<Boolean>
|
||||
get() = _isEnabled.asStateFlow()
|
||||
|
||||
private val _isLocationEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
override val isLocationEnabled: StateFlow<Boolean>
|
||||
get() = _isLocationEnabled.asStateFlow()
|
||||
|
||||
private val _pairedDevices = MutableStateFlow<List<BluetoothDeviceDomain>>(emptyList())
|
||||
override val pairedDevices: StateFlow<List<BluetoothDeviceDomain>>
|
||||
get() = _pairedDevices.asStateFlow()
|
||||
|
||||
private val _scannedDevices: MutableStateFlow<List<BluetoothDeviceDomain>> =
|
||||
MutableStateFlow(emptyList())
|
||||
override val scannedDevices: StateFlow<List<BluetoothDeviceDomain>>
|
||||
get() = _scannedDevices.asStateFlow()
|
||||
|
||||
private var currentClientSocket: BluetoothSocket? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private val bluetoothAdapterStateReceiver = BluetoothAdapterStateReceiver(
|
||||
onBluetoothEnabledChanged = { isEnabled ->
|
||||
_isEnabled.update { _ -> isEnabled }
|
||||
startDiscovery()
|
||||
Log.i("ScanActivity", "Bluetooth enabled status: $isEnabled")
|
||||
},
|
||||
onDiscoveryRunningChanged = { isDiscovering ->
|
||||
_isScanning.update { isDiscovering }
|
||||
},
|
||||
onLocationEnabledChanged = {
|
||||
if (locationManager?.isLocationEnabled == true) {
|
||||
_isLocationEnabled.update { true }
|
||||
} else {
|
||||
_isLocationEnabled.update { false }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private val bluetoothStateReceiver = BluetoothStateReceiver(
|
||||
onDeviceFound = { device, rssi ->
|
||||
if (!hasAllPermissions()) return@BluetoothStateReceiver
|
||||
val newDevice =
|
||||
BluetoothDeviceDomain(device.name ?: "null", device.address, rssi, isScanned = true)
|
||||
_scannedDevices.update { devices ->
|
||||
if (newDevice in devices) devices else devices + newDevice
|
||||
}
|
||||
Log.i(
|
||||
"ScanActivity",
|
||||
"Found new device: ${device.name} ${device.address} $rssi"
|
||||
)
|
||||
},
|
||||
onConnectedStateChanged = { isConnected, device ->
|
||||
if (bluetoothAdapter?.bondedDevices?.contains(device) == true) {
|
||||
_isConnected.update { isConnected }
|
||||
} else {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
_errors.emit("Can't connect to a non-paired device.")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
companion object {
|
||||
// SPP service UUID
|
||||
const val SERVICE_UUID = "00001101-0000-1000-8000-00805F9B34FB"
|
||||
}
|
||||
|
||||
init {
|
||||
updatePairedDevices()
|
||||
_isEnabled.update { bluetoothAdapter.isEnabled }
|
||||
_isLocationEnabled.update { locationManager?.isLocationEnabled == true }
|
||||
context.registerReceiver(
|
||||
bluetoothAdapterStateReceiver,
|
||||
IntentFilter().apply {
|
||||
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
|
||||
addAction(BluetoothAdapter.ACTION_DISCOVERY_STARTED)
|
||||
addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||
addAction(LocationManager.PROVIDERS_CHANGED_ACTION)
|
||||
}
|
||||
}
|
||||
)
|
||||
context.registerReceiver(
|
||||
bluetoothStateReceiver,
|
||||
IntentFilter().apply {
|
||||
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
|
||||
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
|
||||
addAction(BluetoothDevice.ACTION_FOUND)
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun startDiscovery() {
|
||||
if (!hasAllPermissions()) {
|
||||
Toast.makeText(context, "Ошибка: недостаточно разрешений", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (!_isEnabled.value) {
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||
if (locationManager?.isLocationEnabled != true) return
|
||||
}
|
||||
|
||||
updatePairedDevices()
|
||||
_scannedDevices.update { emptyList() }
|
||||
|
||||
if (!bluetoothAdapter.isDiscovering) {
|
||||
bluetoothAdapter.startDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun cancelDiscovery() {
|
||||
if (!hasAllPermissions()) return
|
||||
if (bluetoothAdapter.isDiscovering) {
|
||||
bluetoothAdapter.cancelDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun connectToDevice(device: String): Flow<ConnectionResult> {
|
||||
if (!hasAllPermissions()) {
|
||||
Toast.makeText(context, "Ошибка: нет разрешений", Toast.LENGTH_SHORT).show()
|
||||
return flow {}
|
||||
}
|
||||
return flow {
|
||||
Log.i("BluetoothController", "Connecting to device...")
|
||||
currentClientSocket =
|
||||
bluetoothAdapter.getRemoteDevice(device).createRfcommSocketToServiceRecord(
|
||||
UUID.fromString(SERVICE_UUID)
|
||||
)
|
||||
currentClientSocket?.let { socket ->
|
||||
try {
|
||||
socket.connect()
|
||||
emit(ConnectionResult.ConnectionEstablished)
|
||||
BluetoothDataTransferService(socket).also {
|
||||
dataTransferService = it
|
||||
emitAll(
|
||||
it.listenForIncomingMessages()
|
||||
.map { deviceState ->
|
||||
ConnectionResult.TransferSucceded(deviceState)
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
socket.close()
|
||||
currentClientSocket = null
|
||||
Log.e("BluetoothController", "I/O exception: ${e.message}")
|
||||
emit(ConnectionResult.Error("Connection was interrupted"))
|
||||
}
|
||||
}
|
||||
}.onCompletion {
|
||||
Log.i("BluetoothController", "Connection closed on flow completion.")
|
||||
closeConnection()
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
override suspend fun trySendMessage(message: ByteArray): Boolean {
|
||||
if (!hasAllPermissions()) {
|
||||
return false
|
||||
}
|
||||
if (dataTransferService == null) {
|
||||
return false
|
||||
}
|
||||
dataTransferService?.sendMessage(message)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun closeConnection() {
|
||||
currentClientSocket?.close()
|
||||
currentClientSocket = null
|
||||
Log.i("BluetoothController", "Connection closed")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
context.unregisterReceiver(bluetoothAdapterStateReceiver)
|
||||
context.unregisterReceiver(bluetoothStateReceiver)
|
||||
closeConnection()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun updatePairedDevices() {
|
||||
if (!hasAllPermissions()) return
|
||||
Log.i("ScanActivity", "${bluetoothAdapter?.bondedDevices}")
|
||||
bluetoothAdapter?.bondedDevices?.onEach { device ->
|
||||
_pairedDevices.update {
|
||||
val currentDevice = BluetoothDeviceDomain(
|
||||
name = device.name ?: "null",
|
||||
macAddress = device.address,
|
||||
rssi = 0,
|
||||
isScanned = false
|
||||
)
|
||||
if (currentDevice in pairedDevices.value) {
|
||||
pairedDevices.value
|
||||
} else {
|
||||
_pairedDevices.value + currentDevice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasAllPermissions(): Boolean {
|
||||
val perms = if (Build.VERSION.SDK_INT <= 30) {
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
)
|
||||
} else {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT
|
||||
)
|
||||
}
|
||||
perms.forEach { perm ->
|
||||
if (context.checkSelfPermission(perm) != PackageManager.PERMISSION_GRANTED) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
typealias BluetoothDeviceDomain = BluetoothDevice
|
||||
|
||||
data class BluetoothDevice(
|
||||
val name: String,
|
||||
val macAddress: String,
|
||||
val rssi: Short,
|
||||
val isScanned: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
data class BluetoothUiState(
|
||||
val isEnabled: Boolean = false,
|
||||
val isLocationEnabled: Boolean = false,
|
||||
val isDiscovering: Boolean = false,
|
||||
val isConnected: Boolean = false,
|
||||
val isConnecting: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val scannedBluetoothDevices: List<BluetoothDevice> = emptyList(),
|
||||
val pairedBluetoothDevices: List<BluetoothDevice> = emptyList(),
|
||||
val deviceState: DeviceState? = null
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
|
||||
data class ChangedDeviceStatus(
|
||||
val status: DeviceStatus
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class DeviceState(
|
||||
val status: DeviceStatus = DeviceStatus.ChargeRequired,
|
||||
@Json(name = "charge") val batteryCharge: Int = 10,
|
||||
val flightHeight: Float = 0f,
|
||||
@Json(name = "y") val yaw: Float = 0f,
|
||||
@Json(name = "p") val pitch: Float = 0f,
|
||||
@Json(name = "r") val roll: Float = 0f,
|
||||
@Json(name = "zIn") val zInertial: Float = 0f,
|
||||
val pidSettings: PidSettings? = null
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
enum class DeviceStatus {
|
||||
Idle,
|
||||
IsPreparingForTakeoff,
|
||||
IsFlying,
|
||||
IsBoarding,
|
||||
IsImuCalibration,
|
||||
ChargeRequired;
|
||||
|
||||
fun description(): String {
|
||||
return when (this) {
|
||||
Idle -> "Готово к работе"
|
||||
IsPreparingForTakeoff -> "Подготовка к полёту"
|
||||
IsFlying -> "В полёте"
|
||||
IsBoarding -> "Посадка"
|
||||
IsImuCalibration -> "Калибровка..."
|
||||
ChargeRequired -> "Аккумулятор разряжен"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.JsonDataException
|
||||
import com.squareup.moshi.ToJson
|
||||
|
||||
class DeviceStatusJsonAdapter {
|
||||
@FromJson
|
||||
fun fromJson(deviceStatus: String): DeviceStatus {
|
||||
try {
|
||||
val index: UInt = deviceStatus.toUInt()
|
||||
return DeviceStatus.values()[index.toInt()]
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
throw JsonDataException("Impossible conversation from String to DeviceStatus")
|
||||
}
|
||||
}
|
||||
|
||||
@ToJson
|
||||
fun toJson(deviceStatus: DeviceStatus): String {
|
||||
return DeviceStatus.values().indexOf(deviceStatus).toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
// This dataclass provide message content with its type without any markers
|
||||
data class GeneralMessage(
|
||||
val type: MessageType,
|
||||
val data: String
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
enum class MessageType {
|
||||
UpdateMessage,
|
||||
PidSettings
|
||||
}
|
||||
10
app/src/main/java/com/helible/pilot/dataclasses/PidParams.kt
Normal file
10
app/src/main/java/com/helible/pilot/dataclasses/PidParams.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PidParams (
|
||||
val p: Float,
|
||||
val i: Float,
|
||||
val d: Float
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PidSettingRequiredMessage (
|
||||
val pidSettingOpened: Boolean = true
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PidSettings (
|
||||
@Json(name = "p1") val heightControllerParams: PidParams,
|
||||
@Json(name = "p2") val yawControllerParams: PidParams,
|
||||
@Json(name = "p3") val pitchControllerParams: PidParams
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RotorsDuty(
|
||||
val r1: Short,
|
||||
val r2: Short,
|
||||
val r3: Short
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
data class StickPosition (
|
||||
val x: Float,
|
||||
val y: Float
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
|
||||
data class SticksPosition(
|
||||
@Json(name = "hS") val heightStick: Int,
|
||||
@Json(name = "yS") val yawStick: Int,
|
||||
@Json(name = "pS") val pitchStick: Int
|
||||
)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.helible.pilot.dataclasses
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class StopMessage(
|
||||
val stop: Boolean = true
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.helible.pilot.exceptions
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
class TransferFailedException : IOException("Reading incoming data failed")
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.helible.pilot.permissions
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
|
||||
class PermissionsLauncher {
|
||||
private val permissionsToRequest: Array<String> by lazy {
|
||||
if (Build.VERSION.SDK_INT <= 30) {
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
)
|
||||
} else {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var launcher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>
|
||||
|
||||
@SuppressLint("ComposableNaming")
|
||||
@Composable
|
||||
fun setup(
|
||||
onPermissionResult: (permission: String, isGranted: Boolean) -> Unit,
|
||||
onGranted: () -> Unit,
|
||||
) {
|
||||
launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
||||
onResult = { perms ->
|
||||
permissionsToRequest.forEach { permission ->
|
||||
onPermissionResult(permission, perms[permission] == true)
|
||||
}
|
||||
if (perms.values.all { it }) {
|
||||
onGranted()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun launch(permissions: Array<String> = permissionsToRequest) {
|
||||
launcher.launch(permissions)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.helible.pilot
|
||||
package com.helible.pilot.permissions
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
@@ -6,7 +6,6 @@ import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import com.helible.pilot.components.BluetoothAdminPermissionTextProvider
|
||||
@@ -20,7 +19,7 @@ fun PermissionsRequest(
|
||||
visiblePermissionDialogQueue: SnapshotStateList<String>,
|
||||
dismissCurrentDialog: () -> Unit,
|
||||
activity: Activity,
|
||||
permissionLauncher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>
|
||||
permissionLaunch: (permissions: Array<String>) -> Unit,
|
||||
) {
|
||||
/* Create Dialog windows, which requests all permissions */
|
||||
visiblePermissionDialogQueue.reversed()
|
||||
@@ -52,7 +51,7 @@ fun PermissionsRequest(
|
||||
},
|
||||
onOkClick = {
|
||||
dismissCurrentDialog()
|
||||
permissionLauncher.launch(arrayOf(permission))
|
||||
permissionLaunch(arrayOf(permission))
|
||||
},
|
||||
onContinueClick = {
|
||||
if (activity.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
|
||||
@@ -1,28 +1,26 @@
|
||||
package com.helible.pilot
|
||||
package com.helible.pilot.permissions
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import com.helible.pilot.components.RequiredHardwareFeatures
|
||||
import com.helible.pilot.dataclasses.BluetoothUiState
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@Composable
|
||||
fun RequestHardwareFeatures(
|
||||
activity: Activity,
|
||||
turnOnLocation: Boolean,
|
||||
bluetoothTurnOnState: MutableState<Boolean?>,
|
||||
locationTurnOnState: MutableState<Boolean?>
|
||||
)
|
||||
{
|
||||
bluetoothUiState: BluetoothUiState,
|
||||
) {
|
||||
RequiredHardwareFeatures(
|
||||
title = "Включите Bluetooth",
|
||||
description = "Для работы приложения требуется Bluetooth",
|
||||
confirmButtonText = "Включить Bluetooth",
|
||||
featureState = bluetoothTurnOnState,
|
||||
featureState = bluetoothUiState.isEnabled,
|
||||
requestFeature = {
|
||||
val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
|
||||
activity.startActivity(intent)
|
||||
@@ -30,13 +28,13 @@ fun RequestHardwareFeatures(
|
||||
onDismissRequest = {}
|
||||
)
|
||||
|
||||
if (turnOnLocation) {
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||
RequiredHardwareFeatures(
|
||||
title = "Пожалуйста, включите геолокацию",
|
||||
description = "Для работы с Bluetooth на устройствах с Android 11 и более ранних версиях, " +
|
||||
"требуется геолокация.",
|
||||
confirmButtonText = "Включить геолокацию",
|
||||
featureState = locationTurnOnState,
|
||||
featureState = bluetoothUiState.isLocationEnabled,
|
||||
requestFeature = {
|
||||
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
|
||||
activity.startActivity(intent)
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.helible.pilot.receivers
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.location.LocationManager
|
||||
|
||||
class BluetoothAdapterStateReceiver(
|
||||
private val onBluetoothEnabledChanged: (isBluetoothEnabled: Boolean) -> Unit,
|
||||
private val onDiscoveryRunningChanged: (isDiscoveryRunning: Boolean) -> Unit,
|
||||
private val onLocationEnabledChanged: () -> Unit,
|
||||
) : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
BluetoothAdapter.ACTION_STATE_CHANGED -> {
|
||||
when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) {
|
||||
BluetoothAdapter.STATE_ON -> {
|
||||
onBluetoothEnabledChanged(true)
|
||||
}
|
||||
|
||||
BluetoothAdapter.STATE_OFF -> {
|
||||
onBluetoothEnabledChanged(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LocationManager.PROVIDERS_CHANGED_ACTION -> {
|
||||
onLocationEnabledChanged()
|
||||
}
|
||||
|
||||
BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> {
|
||||
onDiscoveryRunningChanged(false)
|
||||
}
|
||||
|
||||
BluetoothAdapter.ACTION_DISCOVERY_STARTED -> {
|
||||
onDiscoveryRunningChanged(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.helible.pilot.receivers
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
|
||||
class BluetoothStateReceiver(
|
||||
private val onDeviceFound: (device: BluetoothDevice, rssi: Short) -> Unit,
|
||||
private val onConnectedStateChanged: (isConnected: Boolean, BluetoothDevice) -> Unit,
|
||||
) : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent?.getParcelableExtra(
|
||||
BluetoothDevice.EXTRA_DEVICE,
|
||||
BluetoothDevice::class.java
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") intent?.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
|
||||
}
|
||||
when (intent?.action) {
|
||||
BluetoothDevice.ACTION_FOUND -> {
|
||||
val rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE)
|
||||
@SuppressLint("MissingPermission") if (device?.name != null)
|
||||
onDeviceFound(device, rssi)
|
||||
}
|
||||
|
||||
BluetoothDevice.ACTION_ACL_CONNECTED -> {
|
||||
onConnectedStateChanged(true, device ?: return)
|
||||
}
|
||||
|
||||
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
|
||||
onConnectedStateChanged(false, device ?: return)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ fun TestblueTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.helible.pilot.viewmodels
|
||||
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.util.Log
|
||||
import com.helible.pilot.dataclasses.GeneralMessage
|
||||
import com.helible.pilot.dataclasses.MessageType
|
||||
import com.helible.pilot.exceptions.TransferFailedException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.IOException
|
||||
|
||||
const val maxPackageSize = 512 // [bytes]
|
||||
|
||||
class BluetoothDataTransferService(
|
||||
private val socket: BluetoothSocket,
|
||||
) {
|
||||
fun listenForIncomingMessages(): Flow<GeneralMessage> {
|
||||
return flow {
|
||||
if (!socket.isConnected)
|
||||
return@flow
|
||||
val buffer = BufferedInputStream(socket.inputStream, maxPackageSize)
|
||||
while (true) {
|
||||
val message: String = try {
|
||||
buffer.bufferedReader().readLine()
|
||||
} catch (e: IOException) {
|
||||
Log.e("BluetoothController", "Failed to receive incoming data")
|
||||
throw TransferFailedException()
|
||||
}
|
||||
try {
|
||||
val messageType =
|
||||
MessageType.values().elementAtOrNull(message.split(";").first().toInt())
|
||||
val messageData = message.split(";").last()
|
||||
if (messageType != null) {
|
||||
emit(GeneralMessage(messageType, messageData))
|
||||
}
|
||||
} catch (e: NoSuchElementException) {
|
||||
Log.e("BluetoothController", "Message type is invalid: ${e.message}")
|
||||
} catch (e: NumberFormatException) {
|
||||
Log.e("BluetoothController", "Message invalid, may be device buffer congested: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
Log.e("BluetoothController", "Unknown error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
suspend fun sendMessage(bytes: ByteArray): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
socket.outputStream.write(bytes)
|
||||
} catch (e: IOException) {
|
||||
Log.e("BluetoothController", "Failed to write message: $e")
|
||||
return@withContext false
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
package com.helible.pilot.viewmodels
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.helible.pilot.controllers.BluetoothController
|
||||
import com.helible.pilot.controllers.ConnectionResult
|
||||
import com.helible.pilot.dataclasses.BluetoothDevice
|
||||
import com.helible.pilot.dataclasses.BluetoothUiState
|
||||
import com.helible.pilot.dataclasses.ChangedDeviceStatus
|
||||
import com.helible.pilot.dataclasses.DeviceState
|
||||
import com.helible.pilot.dataclasses.DeviceStatus
|
||||
import com.helible.pilot.dataclasses.DeviceStatusJsonAdapter
|
||||
import com.helible.pilot.dataclasses.MessageType
|
||||
import com.helible.pilot.dataclasses.PidSettingRequiredMessage
|
||||
import com.helible.pilot.dataclasses.PidSettings
|
||||
import com.helible.pilot.dataclasses.RotorsDuty
|
||||
import com.helible.pilot.dataclasses.SticksPosition
|
||||
import com.helible.pilot.dataclasses.StopMessage
|
||||
import com.squareup.moshi.JsonDataException
|
||||
import com.squareup.moshi.JsonEncodingException
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BluetoothViewModel(
|
||||
private val bluetoothController: BluetoothController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _selectedDevice: MutableStateFlow<BluetoothDevice?> = MutableStateFlow(null)
|
||||
val selectedDevice: StateFlow<BluetoothDevice?>
|
||||
get() = _selectedDevice.asStateFlow()
|
||||
|
||||
private val _state: MutableStateFlow<BluetoothUiState> = MutableStateFlow(BluetoothUiState())
|
||||
val state: StateFlow<BluetoothUiState> =
|
||||
combine(bluetoothController.scannedDevices, bluetoothController.pairedDevices, _state)
|
||||
{ scannedDevices, pairedDevices, state ->
|
||||
state.copy(
|
||||
scannedBluetoothDevices = scannedDevices.toList(),
|
||||
pairedBluetoothDevices = pairedDevices
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), _state.value)
|
||||
private val _rotorsDuty: MutableStateFlow<RotorsDuty> = MutableStateFlow(RotorsDuty(0, 0, 0))
|
||||
private val _isRotorsTelemetryEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
private val _isConsoleTelemetryEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
private val _sticksPosition: MutableStateFlow<SticksPosition> = MutableStateFlow(SticksPosition(0, 0, 0))
|
||||
|
||||
val rotorsDuty: StateFlow<RotorsDuty>
|
||||
get() = _rotorsDuty.asStateFlow()
|
||||
|
||||
private var deviceConnectionJob: Job? = null
|
||||
|
||||
private val moshi =
|
||||
Moshi.Builder().add(KotlinJsonAdapterFactory()).add(DeviceStatusJsonAdapter()).build()
|
||||
private val statusMessageAdapter = moshi.adapter(ChangedDeviceStatus::class.java)
|
||||
private val deviceStateMessageAdapter = moshi.adapter(DeviceState::class.java)
|
||||
private val pidSittingsMessageAdapter = moshi.adapter(PidSettings::class.java)
|
||||
private val pidSittingsRequiredMessageAdapter =
|
||||
moshi.adapter(PidSettingRequiredMessage::class.java)
|
||||
private val rotorDutyMessageAdapter = moshi.adapter(RotorsDuty::class.java)
|
||||
private val stopAllRotorsMessageAdapter = moshi.adapter(StopMessage::class.java)
|
||||
private val consoleStateMessageAdapter = moshi.adapter(SticksPosition::class.java)
|
||||
|
||||
companion object {
|
||||
const val messageDelimiter = "\n"
|
||||
const val telemetryPauseDuractionMs: Long = 100
|
||||
}
|
||||
|
||||
init {
|
||||
bluetoothController.isConnected.onEach { isConnected ->
|
||||
_state.update { it.copy(isConnected = isConnected) }
|
||||
}.launchIn(viewModelScope)
|
||||
bluetoothController.errors.onEach { error ->
|
||||
_state.update {
|
||||
it.copy(errorMessage = error)
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
bluetoothController.isScanning.onEach { isDiscovering ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
isDiscovering = isDiscovering,
|
||||
)
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
bluetoothController.isEnabled.onEach { isEnabled ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
isEnabled = isEnabled,
|
||||
)
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
bluetoothController.isLocationEnabled.onEach { isLocationEnabled ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLocationEnabled = isLocationEnabled
|
||||
)
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun Flow<ConnectionResult>.listen(): Job {
|
||||
return onEach { result ->
|
||||
when (result) {
|
||||
ConnectionResult.ConnectionEstablished -> {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isConnected = true,
|
||||
isConnecting = false,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ConnectionResult.TransferSucceded -> {
|
||||
try {
|
||||
when (result.message.type) {
|
||||
MessageType.PidSettings -> {
|
||||
val newPidSettings =
|
||||
pidSittingsMessageAdapter.fromJson(result.message.data)
|
||||
_state.update {
|
||||
it.copy(
|
||||
deviceState = it.deviceState?.copy(pidSettings = newPidSettings)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MessageType.UpdateMessage -> {
|
||||
val newDeviceState =
|
||||
deviceStateMessageAdapter.fromJson(result.message.data)
|
||||
if (newDeviceState != null) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
deviceState = newDeviceState.copy(pidSettings = it.deviceState?.pidSettings)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: JsonDataException) {
|
||||
Log.e("BluetoothVM", "Failed to parse message: ${result.message.data}")
|
||||
} catch (e: JsonEncodingException) {
|
||||
Log.e("BluetoothVM", "Failed to decode message: ${result.message.data}")
|
||||
} catch (e: Exception) {
|
||||
Log.e("BluetoothVM", "Unknown error on message: ${result.message.data}")
|
||||
}
|
||||
}
|
||||
|
||||
is ConnectionResult.Error -> {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isConnected = false,
|
||||
isConnecting = false,
|
||||
errorMessage = result.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.catch { throwable ->
|
||||
Log.e(
|
||||
"BluetoothVM",
|
||||
"Error occured while data transfer: ${throwable.localizedMessage}"
|
||||
)
|
||||
bluetoothController.closeConnection()
|
||||
_state.update {
|
||||
it.copy(
|
||||
isConnected = false,
|
||||
isConnecting = false,
|
||||
deviceState = null
|
||||
)
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun connectToDevice(device: String) {
|
||||
if (_state.value.isConnected or _state.value.isConnecting) {
|
||||
return
|
||||
}
|
||||
_state.update { it.copy(isConnecting = true) }
|
||||
deviceConnectionJob = bluetoothController
|
||||
.connectToDevice(device)
|
||||
.listen()
|
||||
}
|
||||
|
||||
fun disconnectFromDevice() {
|
||||
deviceConnectionJob?.cancel()
|
||||
bluetoothController.closeConnection()
|
||||
_isConsoleTelemetryEnabled.update { false }
|
||||
_isRotorsTelemetryEnabled.update { false }
|
||||
_state.update {
|
||||
it.copy(
|
||||
isConnecting = false,
|
||||
isConnected = false,
|
||||
deviceState = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectDevice(selectedDevice: BluetoothDevice?) {
|
||||
_selectedDevice.update { selectedDevice }
|
||||
}
|
||||
|
||||
fun startScan() {
|
||||
selectDevice(null)
|
||||
bluetoothController.startDiscovery()
|
||||
}
|
||||
|
||||
fun cancelScan() {
|
||||
bluetoothController.cancelDiscovery()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
cancelScan()
|
||||
bluetoothController.onDestroy()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun startImuCalibration() {
|
||||
viewModelScope.launch {
|
||||
val message = statusMessageAdapter.toJson(
|
||||
ChangedDeviceStatus(DeviceStatus.IsImuCalibration)
|
||||
) + messageDelimiter
|
||||
val isSuccess = bluetoothController.trySendMessage(
|
||||
message.toByteArray()
|
||||
)
|
||||
if (!isSuccess) {
|
||||
Log.e("BluetoothVM", "Failed to start IMU calibration: $message")
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(
|
||||
deviceState = it.deviceState?.copy(status = DeviceStatus.IsImuCalibration)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPidSettings() {
|
||||
viewModelScope.launch {
|
||||
val message =
|
||||
pidSittingsRequiredMessageAdapter.toJson(PidSettingRequiredMessage(true)) + messageDelimiter
|
||||
Log.i("BluetoothVM", "Requested PID settings: $message")
|
||||
val isSuccess = bluetoothController.trySendMessage(
|
||||
message.toByteArray()
|
||||
)
|
||||
if (!isSuccess) {
|
||||
Log.e("BluetoothVM", "Failed to request PID settings: $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applyPidSettings(pidSettings: PidSettings) {
|
||||
viewModelScope.launch {
|
||||
val message = pidSittingsMessageAdapter.toJson(pidSettings) + messageDelimiter
|
||||
val isSuccess = bluetoothController.trySendMessage(message.toByteArray())
|
||||
if (!isSuccess) {
|
||||
Log.e("BluetoothVM", "Failed to request PID settings: $message")
|
||||
_state.update {
|
||||
it.copy(errorMessage = "Не удалось обновить значения PID")
|
||||
}
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(deviceState = it.deviceState?.copy(pidSettings = pidSettings))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPidSettings() {
|
||||
Log.i("BluetoothVM", "PidSettings cleared")
|
||||
_state.update {
|
||||
it.copy(deviceState = it.deviceState?.copy(pidSettings = null))
|
||||
}
|
||||
Log.i("BluetoothVM", "PidSettings: ${_state.value.deviceState?.pidSettings}")
|
||||
}
|
||||
|
||||
private fun sendRotorsDuty() {
|
||||
viewModelScope.launch {
|
||||
val message = rotorDutyMessageAdapter.toJson(
|
||||
_rotorsDuty.value
|
||||
) + messageDelimiter
|
||||
val isSuccess = bluetoothController.trySendMessage(
|
||||
message.toByteArray()
|
||||
)
|
||||
if (!isSuccess) {
|
||||
Log.e("BluetoothVM", "Failed to send rotors telemetry: $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startRotorsConfigurationTelemetry() {
|
||||
Log.i("BluetoothVM", "Start send rotors configuration telemetry...")
|
||||
if(_isRotorsTelemetryEnabled.value) return
|
||||
_isRotorsTelemetryEnabled.update { true }
|
||||
flow {
|
||||
while(_isRotorsTelemetryEnabled.value) {
|
||||
emit(Unit)
|
||||
delay(telemetryPauseDuractionMs)
|
||||
}
|
||||
}.onEach{
|
||||
sendRotorsDuty()
|
||||
Log.d("BluetoothVM", "Sended rotors telemetry")
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun stopRotorsConfigurationTelemetry() {
|
||||
Log.i("BluetoothVM", "Stop send rotors configuration periodically...")
|
||||
_isRotorsTelemetryEnabled.update { false }
|
||||
}
|
||||
|
||||
fun setRotorsDuty(newRotorsDuty: RotorsDuty) {
|
||||
_rotorsDuty.update { newRotorsDuty }
|
||||
}
|
||||
|
||||
fun stopRotors() {
|
||||
viewModelScope.launch {
|
||||
val message = stopAllRotorsMessageAdapter.toJson(StopMessage()) + messageDelimiter
|
||||
val isSuccess = bluetoothController.trySendMessage(message.toByteArray())
|
||||
if (!isSuccess) {
|
||||
Log.e("BluetoothVM", "Failed to stop all rotors: $message")
|
||||
_state.update {
|
||||
it.copy(errorMessage = "Не удалось остановить моторы!")
|
||||
}
|
||||
} else {
|
||||
_rotorsDuty.update { RotorsDuty(0, 0, 0) }
|
||||
_isConsoleTelemetryEnabled.update { false }
|
||||
}
|
||||
}
|
||||
}
|
||||
fun startTakeoff() {
|
||||
viewModelScope.launch {
|
||||
val message = statusMessageAdapter.toJson(ChangedDeviceStatus(DeviceStatus.IsFlying)) + messageDelimiter
|
||||
val isSuccess = bluetoothController.trySendMessage(message.toByteArray())
|
||||
if(!isSuccess) {
|
||||
Log.e("BluetoothVM", "Failed to start takeoff: $message")
|
||||
_state.update {
|
||||
it.copy(errorMessage = "Не удалось начать полёт!")
|
||||
}
|
||||
} else {
|
||||
_state.update { it.copy(deviceState = it.deviceState?.copy(status = DeviceStatus.IsFlying)) }
|
||||
startConsoleTelemetrySending()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startOnboarding() {
|
||||
viewModelScope.launch {
|
||||
val message = statusMessageAdapter.toJson(ChangedDeviceStatus(DeviceStatus.IsBoarding)) + messageDelimiter
|
||||
val isSuccess = bluetoothController.trySendMessage(message.toByteArray())
|
||||
if(!isSuccess) {
|
||||
Log.e("BluetoothVM", "Failed to start onboarding: $message")
|
||||
} else {
|
||||
_state.update { it.copy(deviceState = it.deviceState?.copy(status = DeviceStatus.IsBoarding)) }
|
||||
stopConsoleTelemetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun changeHeightStickPosition(newHeightStickPosition: Int) {
|
||||
_sticksPosition.update {
|
||||
it.copy(heightStick = newHeightStickPosition)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeYawStickPosition(newYawStickPosition: Int) {
|
||||
_sticksPosition.update {
|
||||
it.copy(yawStick = newYawStickPosition)
|
||||
}
|
||||
}
|
||||
|
||||
fun changePitchStickPosition(newPitchStickPosition: Int) {
|
||||
_sticksPosition.update {
|
||||
it.copy(pitchStick = newPitchStickPosition)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendConsoleState() {
|
||||
viewModelScope.launch {
|
||||
val message = consoleStateMessageAdapter.toJson(
|
||||
_sticksPosition.value
|
||||
) + messageDelimiter
|
||||
val isSuccess = bluetoothController.trySendMessage(
|
||||
message.toByteArray()
|
||||
)
|
||||
Log.i("BluetoothVM", "Sended telemetry message: $message")
|
||||
if (!isSuccess) {
|
||||
Log.e("BluetoothVM", "Failed to send console telemetry: $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startConsoleTelemetrySending() {
|
||||
if(_isConsoleTelemetryEnabled.value) {
|
||||
return
|
||||
}
|
||||
_isConsoleTelemetryEnabled.update { true }
|
||||
flow {
|
||||
while (_isConsoleTelemetryEnabled.value) {
|
||||
emit(Unit)
|
||||
delay(telemetryPauseDuractionMs)
|
||||
}
|
||||
}.onEach {
|
||||
sendConsoleState()
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun stopConsoleTelemetry() {
|
||||
_isConsoleTelemetryEnabled.update { false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.helible.pilot.viewmodels
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.helible.pilot.controllers.AndroidBluetoothController
|
||||
|
||||
@ExperimentalStdlibApi
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
class BluetoothViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return BluetoothViewModel(
|
||||
bluetoothController = AndroidBluetoothController(context)
|
||||
) as T
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.helible.pilot.viewmodels
|
||||
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class PermissionDialogViewModel : ViewModel() {
|
||||
val visiblePermissionDialogQueue = mutableStateListOf<String>()
|
||||
|
||||
fun dismissDialog() {
|
||||
visiblePermissionDialogQueue.removeFirst()
|
||||
}
|
||||
|
||||
fun onPermissionResult(permission: String, isGranted: Boolean) {
|
||||
if (!isGranted && !visiblePermissionDialogQueue.contains(permission)) {
|
||||
visiblePermissionDialogQueue.add(permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PreferencesViewModel(
|
||||
private val preferencesStorage: SavedPreferences,
|
||||
) : ViewModel() {
|
||||
val preferences: AppPreferences? get() = preferencesStorage.getPreferences()
|
||||
fun savePreferences(savedPreferences: AppPreferences) {
|
||||
preferencesStorage.savePreferences(
|
||||
preferences = savedPreferences
|
||||
)
|
||||
}
|
||||
|
||||
fun clearPreferences() {
|
||||
preferencesStorage.clearPreferences()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.helible.pilot.viewmodels
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class AppPreferences(
|
||||
val deviceName: String,
|
||||
val deviceAddress: String,
|
||||
)
|
||||
|
||||
interface SavedPreferences {
|
||||
fun getPreferences(): AppPreferences?
|
||||
fun savePreferences(preferences: AppPreferences)
|
||||
fun clearPreferences()
|
||||
}
|
||||
|
||||
class SavedPreferencesImpl(private val sharedPreferences: SharedPreferences) : SavedPreferences {
|
||||
private val moshi: Moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||
private val preferencesAdapter: JsonAdapter<AppPreferences> =
|
||||
moshi.adapter(AppPreferences::class.java)
|
||||
|
||||
override fun getPreferences(): AppPreferences? {
|
||||
val json = sharedPreferences.getString("preferences", null) ?: return null
|
||||
return preferencesAdapter.fromJson(json)
|
||||
}
|
||||
|
||||
override fun savePreferences(preferences: AppPreferences) {
|
||||
sharedPreferences.edit()
|
||||
.putString("preferences", preferencesAdapter.toJson(preferences))
|
||||
.apply()
|
||||
|
||||
}
|
||||
|
||||
override fun clearPreferences() {
|
||||
sharedPreferences.edit().remove("preferences").apply()
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/drawable/cancel.xml
Normal file
10
app/src/main/res/drawable/cancel.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?android:colorPrimary">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M336,680L480,536L624,680L680,624L536,480L680,336L624,280L480,424L336,280L280,336L424,480L280,624L336,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/code_blocks.xml
Normal file
10
app/src/main/res/drawable/code_blocks.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?android:colorPrimary">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M384,624L440,567L353,480L440,393L384,336L240,480L384,624ZM576,624L720,480L576,336L520,393L607,480L520,567L576,624ZM200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM200,760L760,760Q760,760 760,760Q760,760 760,760L760,200Q760,200 760,200Q760,200 760,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760ZM200,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200Z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/construction.xml
Normal file
10
app/src/main/res/drawable/construction.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?android:colorPrimary">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M756,840L537,621L621,537L840,756L756,840ZM204,840L120,756L396,480L328,412L300,440L249,389L249,471L221,499L100,378L128,350L210,350L160,300L302,158Q322,138 345,129Q368,120 392,120Q416,120 439,129Q462,138 482,158L390,250L440,300L412,328L480,396L570,306Q566,295 563.5,283Q561,271 561,259Q561,200 601.5,159.5Q642,119 701,119Q716,119 729.5,122Q743,125 757,131L658,230L730,302L829,203Q836,217 838.5,230.5Q841,244 841,259Q841,318 800.5,358.5Q760,399 701,399Q689,399 677,397Q665,395 654,390L204,840Z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/controller_gen.xml
Normal file
10
app/src/main/res/drawable/controller_gen.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?android:colorPrimary">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,720Q580,720 650,650Q720,580 720,480Q720,380 650,310Q580,240 480,240Q380,240 310,310Q240,380 240,480Q240,580 310,650Q380,720 480,720ZM480,640Q414,640 367,593Q320,546 320,480Q320,414 367,367Q414,320 480,320Q546,320 593,367Q640,414 640,480Q640,546 593,593Q546,640 480,640ZM452,508Q463,519 480,519Q497,519 508,508L564,452Q575,441 575,424Q575,407 564,396Q553,385 536,385Q519,385 508,396L452,452Q441,463 441,480Q441,497 452,508ZM200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM200,760L760,760Q760,760 760,760Q760,760 760,760L760,200Q760,200 760,200Q760,200 760,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760ZM200,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200Z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/energy.xml
Normal file
10
app/src/main/res/drawable/energy.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?android:colorPrimary">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M360,880L360,840Q360,808 384,786.5Q408,765 440,760L440,760L440,474Q418,466 401.5,451Q385,436 374,416L298,436Q262,444 229.5,430Q197,416 188,382L362,338Q370,297 400,270Q430,243 472,240L496,150Q506,115 534.5,94.5Q563,74 598,82L548,262Q573,278 586.5,304Q600,330 600,360Q600,373 597,385.5Q594,398 590,410L644,466Q669,492 673.5,527Q678,562 654,586L534,466Q531,469 527.5,470.5Q524,472 520,474L520,760L520,760Q552,765 576,786.5Q600,808 600,840L600,880L360,880ZM480,420Q505,420 522.5,402.5Q540,385 540,360Q540,335 522.5,317.5Q505,300 480,300Q455,300 437.5,317.5Q420,335 420,360Q420,385 437.5,402.5Q455,420 480,420Z" />
|
||||
</vector>
|
||||
BIN
app/src/main/res/drawable/helicopter.png
Normal file
BIN
app/src/main/res/drawable/helicopter.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
10
app/src/main/res/drawable/helicopter_icon.xml
Normal file
10
app/src/main/res/drawable/helicopter_icon.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?android:colorPrimary">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M360,520L360,280L360,280Q260,280 190,350Q120,420 120,520L120,520L360,520ZM520,680L520,280L440,280L440,600L120,600L120,680Q120,680 120,680Q120,680 120,680L520,680ZM600,552L840,528L840,480L600,480L600,552ZM520,880L120,880L120,800L520,800L520,880ZM600,760L120,760Q87,760 63.5,736.5Q40,713 40,680L40,520Q40,386 133,293Q226,200 360,200L600,200L600,400L800,400L840,320L920,320L920,600L600,632L600,760ZM760,160L120,160L120,80L760,80L760,160ZM600,552L600,480L600,480L600,552L600,552ZM520,680L520,680Q520,680 520,680Q520,680 520,680L520,680L520,680L520,680L520,680L520,680Z" />
|
||||
</vector>
|
||||
@@ -1,5 +1,10 @@
|
||||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FF000000" android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z"/>
|
||||
<vector android:height="24dp"
|
||||
android:tint="#000000"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24"
|
||||
android:width="24dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z" />
|
||||
</vector>
|
||||
|
||||
10
app/src/main/res/drawable/instant_mix.xml
Normal file
10
app/src/main/res/drawable/instant_mix.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?android:colorPrimary">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M200,800L200,520L120,520L120,440L360,440L360,520L280,520L280,800L200,800ZM200,360L200,160L280,160L280,360L200,360ZM360,360L360,280L440,280L440,160L520,160L520,280L600,280L600,360L360,360ZM440,800L440,440L520,440L520,800L440,800ZM680,800L680,680L600,680L600,600L840,600L840,680L760,680L760,800L680,800ZM680,520L680,160L760,160L760,520L680,520Z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/joystick.xml
Normal file
10
app/src/main/res/drawable/joystick.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?android:colorPrimary">
|
||||
<path
|
||||
android:fillColor="@android:color/holo_orange_light"
|
||||
android:pathData="M272,520L480,640Q480,640 480,640Q480,640 480,640L688,520L520,423L520,560L440,560L440,423L272,520ZM440,331L440,314Q396,301 368,264.5Q340,228 340,180Q340,122 381,81Q422,40 480,40Q538,40 579,81Q620,122 620,180Q620,228 592,264.5Q564,301 520,314L520,331L800,492Q819,503 829.5,521.5Q840,540 840,562L840,638Q840,660 829.5,678.5Q819,697 800,708L520,869Q501,880 480,880Q459,880 440,869L160,708Q141,697 130.5,678.5Q120,660 120,638L120,562Q120,540 130.5,521.5Q141,503 160,492L440,331ZM440,709L200,571L200,638Q200,638 200,638Q200,638 200,638L480,800Q480,800 480,800Q480,800 480,800L760,638Q760,638 760,638Q760,638 760,638L760,571L520,709Q501,720 480,720Q459,720 440,709ZM480,240Q505,240 522.5,222.5Q540,205 540,180Q540,155 522.5,137.5Q505,120 480,120Q455,120 437.5,137.5Q420,155 420,180Q420,205 437.5,222.5Q455,240 480,240ZM480,800Q480,800 480,800Q480,800 480,800L480,800L480,800Q480,800 480,800Q480,800 480,800L480,800Q480,800 480,800Q480,800 480,800L480,800Q480,800 480,800Q480,800 480,800L480,800L480,800Z" />
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/logout.xml
Normal file
11
app/src/main/res/drawable/logout.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?android:colorPrimary"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L480,120L480,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L480,760L480,840L200,840ZM640,680L585,622L687,520L360,520L360,440L687,440L585,338L640,280L840,480L640,680Z" />
|
||||
</vector>
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="500" android:viewportHeight="500">
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="500"
|
||||
android:viewportHeight="500">
|
||||
<path
|
||||
android:fillColor="?android:colorPrimary"
|
||||
android:pathData="M 15 340 L 80 340 Q 95 340 95 355 L 95 485 Q 95 500 80 500 L 15 500 Q 0 500 0 485 L 0 355 Q 0 340 15 340 Z" />
|
||||
|
||||
10
app/src/main/res/drawable/stick_background.xml
Normal file
10
app/src/main/res/drawable/stick_background.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="210dp"
|
||||
android:height="210dp"
|
||||
android:viewportWidth="210"
|
||||
android:viewportHeight="210">
|
||||
<path
|
||||
android:pathData="M0.5,105.5a105,105 0,1 0,210 0a105,105 0,1 0,-210 0z"
|
||||
android:fillColor="#000000"
|
||||
android:strokeColor="#00000000"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/stick_dot.xml
Normal file
10
app/src/main/res/drawable/stick_dot.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="95dp"
|
||||
android:height="95dp"
|
||||
android:viewportWidth="95"
|
||||
android:viewportHeight="95">
|
||||
<path
|
||||
android:pathData="M0,47.5a47.5,47.5 0,1 0,95 0a47.5,47.5 0,1 0,-95 0z"
|
||||
android:fillColor="#f83a14"
|
||||
android:strokeColor="#00000000"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/sync.xml
Normal file
10
app/src/main/res/drawable/sync.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?android:colorPrimary">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M160,800L160,720L270,720L254,706Q202,660 181,601Q160,542 160,482Q160,371 226.5,284.5Q293,198 400,170L400,254Q328,280 284,342.5Q240,405 240,482Q240,527 257,569.5Q274,612 310,648L320,658L320,560L400,560L400,800L160,800ZM560,790L560,706Q632,680 676,617.5Q720,555 720,478Q720,433 703,390.5Q686,348 650,312L640,302L640,400L560,400L560,160L800,160L800,240L690,240L706,254Q755,303 777.5,360.5Q800,418 800,478Q800,589 733.5,675.5Q667,762 560,790Z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/tune.xml
Normal file
10
app/src/main/res/drawable/tune.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?android:colorPrimary">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M440,840L440,600L520,600L520,680L840,680L840,760L520,760L520,840L440,840ZM120,760L120,680L360,680L360,760L120,760ZM280,600L280,520L120,520L120,440L280,440L280,360L360,360L360,600L280,600ZM440,520L440,440L840,440L840,520L440,520ZM600,360L600,120L680,120L680,200L840,200L840,280L680,280L680,360L600,360ZM120,280L120,200L520,200L520,280L120,280Z" />
|
||||
</vector>
|
||||
@@ -1,3 +1,7 @@
|
||||
<resources>
|
||||
<string name="app_name">Digital Pilot</string>
|
||||
<string name="calibration_description">Расположите устройство на ровной горизонтальной поверхности, чтобы сани вертолета полностью лежали на ней. Нажмите кнопку калибровки ниже и ждите её окончания, не создавая тряски.</string>
|
||||
<string name="p_pid_value_description">Сначала подберите значение коэффицента P, которое балансирует между слишком низкой и слишком высокой чувствительностью.</string>
|
||||
<string name="i_pid_value_description">Затем подберите значение коэффицента I, которое уберёт нежелательный дрейф, но не повлияет на отзывчивость.</string>
|
||||
<string name="d_pid_value_description">После установите значение коэффицента D таким образом, чтобы обеспечить более стабильное и плавное управление.</string>
|
||||
</resources>
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Testblue" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
<style name="Theme.Main" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -1,9 +1,8 @@
|
||||
package com.helible.pilot
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id("com.android.application") version "8.1.0" apply false
|
||||
id("com.android.application") version "8.2.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.8.10" apply false
|
||||
id("org.jetbrains.kotlin.jvm") version "1.8.10" apply false
|
||||
}
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
#Sun Aug 13 15:00:54 KRAT 2023
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -3,6 +3,7 @@ pluginManagement {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
maven(url = "https://jitpack.io")
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
@@ -10,6 +11,7 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven(url = "https://jitpack.io")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user