Compare commits

25 Commits
Author SHA1 Message Date
gogacoder 682af406f5 Correct discarding for invalid messages and small fixes 2024-03-19 19:14:51 +07:00
gogacoder 0557622dc3 Correct rotors order 2024-03-19 19:13:28 +07:00
gogacoder 59eb3c7440 Translation error fix 2024-03-13 21:47:16 +07:00
gogacoder d2f8d0ff06 Console screen implemented 2024-03-12 22:44:33 +07:00
gogacoder 3e1c5edc19 Litle improvements 2024-03-09 09:36:06 +07:00
gogacoder 027116e18e PID contrllers settings page was added 2024-03-08 23:07:18 +07:00
gogacoder c8abfd94c3 Rotors test page was added 2024-03-08 23:05:38 +07:00
gogacoder 0763c2e1df Message delimeter simplified 2024-03-01 22:36:43 +07:00
gogacoder 3ac39136c6 Data transfer service rewritten 2024-03-01 22:11:06 +07:00
gogacoder 5e0f2f1bb7 Joystick & Pid Configuration
Flexible PID configuration and joysticks were added
2024-02-28 23:41:30 +07:00
gogacoder 3517414ec1 Data tranfering improvement 2024-02-09 19:28:49 +07:00
gogacoder 3b62743481 Better UI layout on the device screen 2024-01-27 23:14:26 +07:00
gogacoder 18bd21fba1 Global navigation was implemented 2024-01-02 22:05:23 +07:00
gogacoder 77a3b19b24 UI Previews
UI Previews was added for service dialogs
2024-01-02 20:25:49 +07:00
gogacoder 70cd547fb7 UI Previews
Preview for every UI component was added. More flexible DeviceItem component.
2024-01-02 20:17:25 +07:00
gogacoder efa93ab912 Device screen was implemented 2024-01-01 21:56:23 +07:00
gogacoder d7f3bf386d ViewModels with lifecycle integration, new Device class, code reformat 2023-12-30 22:49:47 +07:00
gogacoder 7436599ad3 Autodisconnect from device, when the flight screen remove from the backstack 2023-10-03 21:39:59 +07:00
gogacoder ad13a7e958 Now app can create connection and talk with the device 2023-09-30 22:08:51 +07:00
gogacoder 0c949a9bda layout fix
CircularProgressIndicator was moved into the devices list.
2023-09-28 21:55:35 +07:00
gogacoder 415b5ef0d8 Paired devices list was added 2023-09-27 21:55:07 +07:00
gogacoder 80390b09ba Critical bugs were fixed
More correct permissions processing and compact code with flows
2023-09-15 21:51:35 +07:00
gogacoder a956bc3564 First commit 2023-08-19 22:04:05 +07:00
gogacoder 833f8a99c1 .gitignore 2023-08-19 22:03:47 +07:00
gogacoder d43982d8f7 Initial commit 2023-08-19 21:58:28 +07:00
116 changed files with 5998 additions and 1175 deletions
+12 -328
View File
@@ -1,331 +1,15 @@
### Android ###
# Built application files
*.apk
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml *.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
.idea/caches
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
# Google Services (e.g. APIs or Firebase)
google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
### Android Patch ###
gen-external-apklibs
### AndroidStudio ###
# Covers files to be ignored for android development using Android Studio.
# Built application files
# Files for the ART/Dalvik VM
# Java class files
# Generated files
# Gradle files
.gradle .gradle
/local.properties
# Signing files /.idea/caches
.signing/ /.idea/libraries
/.idea/modules.xml
# Local configuration file (sdk path, etc) /.idea/workspace.xml
/.idea/navEditor.xml
# Proguard folder generated by Eclipse /.idea/assetWizardSettings.xml
# Log Files
# Android Studio
/*/build/
/*/local.properties
/*/out
/*/*/build
/*/*/production
*.ipr
*~
*.swp
# Android Patch
# External native build folder generated in Android Studio 2.2 and later
# NDK
obj/
# IntelliJ IDEA
*.iws
/out/
# User-specific configurations
.idea/caches/
.idea/libraries/
.idea/shelf/
.idea/.name
.idea/compiler.xml
.idea/copyright/profiles_settings.xml
.idea/encodings.xml
.idea/misc.xml
.idea/modules.xml
.idea/scopes/scope_settings.xml
.idea/vcs.xml
.idea/jsLibraryMappings.xml
.idea/datasources.xml
.idea/dataSources.ids
.idea/sqlDataSources.xml
.idea/dynamic.xml
.idea/uiDesigner.xml
# OS-specific files
.DS_Store .DS_Store
.DS_Store? /build
._* /captures
.Spotlight-V100 .externalNativeBuild
.Trashes .cxx
ehthumbs.db local.properties
Thumbs.db
# Legacy Eclipse project files
.classpath
.project
.cproject
.settings/
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.war
*.ear
# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
hs_err_pid*
## Plugin-specific files:
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Mongo Explorer plugin
.idea/mongoSettings.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
### AndroidStudio Patch ###
!/gradle/wrapper/gradle-wrapper.jar
### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
# IntelliJ
# mpeltonen/sbt-idea plugin
# JIRA plugin
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### Intellij Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
.idea/sonarlint
### Java ###
# Compiled class file
# Log file
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
# Package Files #
*.jar
*.nar
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
### Linux ###
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### macOS ###
# General
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.TemporaryItems
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Windows ###
# Windows thumbnail cache files
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
Generated
+1
View File
@@ -0,0 +1 @@
pilot
-123
View File
@@ -1,123 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>
-5
View File
@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>
+10
View 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>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<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>
</project>
+41
View File
@@ -0,0 +1,41 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.20" /> <option name="version" value="1.8.10" />
</component> </component>
</project> </project>
+10
View 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>
+9
View File
@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
-52
View File
@@ -1,52 +0,0 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
signingConfigs {
debug {
storeFile file('/home/gogacoder/AndroidStudioProjects/keychain/key.jks')
storePassword '123456'
keyPassword '123456'
keyAlias 'key0'
}
}
namespace 'com.listerk.helible'
compileSdk 34
defaultConfig {
applicationId "com.listerk.helible"
minSdk 30
targetSdk 34
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
+75
View File
@@ -0,0 +1,75 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.helible.pilot"
compileSdk = 33
defaultConfig {
applicationId = "com.helible.pilot"
minSdk = 28
targetSdk = 33
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.3"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("androidx.activity:activity-compose:1.7.2")
implementation(platform("androidx.compose:compose-bom:2023.03.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
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.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")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
Binary file not shown.
@@ -4,8 +4,8 @@
"type": "APK", "type": "APK",
"kind": "Directory" "kind": "Directory"
}, },
"applicationId": "com.listerk.helible", "applicationId": "com.helible.pilot",
"variantName": "debug", "variantName": "release",
"elements": [ "elements": [
{ {
"type": "SINGLE", "type": "SINGLE",
@@ -13,7 +13,7 @@
"attributes": [], "attributes": [],
"versionCode": 1, "versionCode": 1,
"versionName": "1.0", "versionName": "1.0",
"outputFile": "app-debug.apk" "outputFile": "app-release.apk"
} }
], ],
"elementType": "File" "elementType": "File"
@@ -1,13 +1,11 @@
package com.listerk.helible package com.helible.pilot
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *
@@ -19,6 +17,6 @@ class ExampleInstrumentedTest {
fun useAppContext() { fun useAppContext() {
// Context of the app under test. // Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.listerk.helible", appContext.packageName) assertEquals("com.gogacoder.testblue", appContext.packageName)
} }
} }
+32 -18
View File
@@ -2,19 +2,35 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<!-- Legacy permissions (API 30 and below) --> <!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/> <uses-permission
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> android:name="android.permission.BLUETOOTH"
<!--<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30"/> android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="30"/> <uses-permission
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" android:maxSdkVersion="30"/>--> 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:usesPermissionFlags="neverForLocation"/> <uses-feature
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> android:name="android.hardware.bluetooth"
android:required="true" />
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<uses-feature android:name="android.hardware.bluetooth_le" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -24,16 +40,14 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.HeliBLE"> android:theme="@style/Theme.Main"
tools:targetApi="31">
<activity <activity
android:name=".ControlActivity" android:name="com.helible.pilot.MainActivity"
android:exported="false" /> android:exported="true"
<activity android:theme="@style/Theme.Main">
android:name=".MainActivity"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
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()
@@ -0,0 +1,247 @@
package com.helible.pilot
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.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 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
class MainActivity : ComponentActivity() {
// TODO: move text strings to resources
private val preferences by lazy {
SavedPreferencesImpl(getSharedPreferences(packageName, MODE_PRIVATE))
}
private val preferencesViewModel by lazy {
PreferencesViewModel(preferences)
}
@ExperimentalStdlibApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
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 bluetoothState by bluetoothViewModel.state.collectAsState()
val selectedDevice by bluetoothViewModel.selectedDevice.collectAsState()
val rotorsDuty by bluetoothViewModel.rotorsDuty.collectAsState()
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,
permissionLaunch = { perms -> permissionLauncher.launch(perms) }
)
RequestHardwareFeatures(
activity = this,
bluetoothUiState = bluetoothState
)
NavHost(
navController = navController,
startDestination = "device"
) {
composable("scanner") {
ScannerScreen(
bluetoothState = bluetoothState,
selectedDevice = selectedDevice,
startScan = { bluetoothViewModel.startScan() },
cancelScan = { bluetoothViewModel.cancelScan() },
choiceDevice = { device -> bluetoothViewModel.selectDevice(device) },
onScreenChanged = {
bluetoothViewModel.cancelScan()
val device = selectedDevice
if (device == null) {
preferencesViewModel.clearPreferences()
} else {
preferencesViewModel.savePreferences(
AppPreferences(
deviceName = device.name,
deviceAddress = device.macAddress
)
)
}
navController.navigate("device")
Log.i(
"ScanActivity",
"Preferences: ${preferencesViewModel.preferences}"
)
}
)
}
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 {}
}
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()
}
}
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() }
)
}
}
}
}
}
}
}
@@ -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 = {}
)
}
}
@@ -0,0 +1,51 @@
package com.helible.pilot.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Divider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun RequiredHardwareFeatures(
title: String,
description: String,
confirmButtonText: String,
featureState: Boolean,
requestFeature: () -> Unit,
onDismissRequest: () -> Unit,
) {
if (!featureState) {
AlertDialog(
confirmButton = {
Divider()
TextButton(onClick = requestFeature, modifier = Modifier.fillMaxWidth()) {
Text(text = confirmButtonText)
}
},
onDismissRequest = onDismissRequest,
text = {
Text(
text = description
)
},
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 = "Эта страница пока не готова и находится на стадии разработки")
}
}
}
@@ -0,0 +1,146 @@
package com.helible.pilot.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.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
fun PermissionDialog(
permissionTextProvider: PermissionTextProvider,
isPermanentDeclined: Boolean,
onDismiss: () -> Unit,
onOkClick: () -> Unit,
onContinueClick: () -> Unit,
onGoToAppSettingsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = "Требуется разрешение") },
confirmButton = {
Column(
modifier = Modifier.fillMaxWidth()
) {
Divider()
Text(
text = if (isPermanentDeclined) {
"Выдать разрешение"
} else {
"OK"
},
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clickable {
if (isPermanentDeclined) {
onGoToAppSettingsClick()
} else {
onOkClick()
}
}
.padding(16.dp)
)
}
},
dismissButton = {
if (isPermanentDeclined)
Box(modifier = Modifier.fillMaxWidth()) {
Divider()
Text(
text = "Снова проверить наличие разрешения",
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues = PaddingValues(top = 10.dp))
.clickable { onContinueClick() },
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
else
Unit
},
text = {
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) {
"Похоже вы навсегда запретили приложению доступ к геолокации. " +
"Вы можете зайти в настройки, чтобы выдать это разрешение."
} else {
"Приложению необходимо разрешение для определения местоположения " +
"для работы с Bluetooth на устройствах с Android 11 и ниже."
}
}
}
class BluetoothScanPermissionTextProvider : PermissionTextProvider {
override fun getDescription(isPermanentDeclined: Boolean): String {
return if (isPermanentDeclined) {
"Похоже вы навсегда запретили приложению доступ к сканированию по Bluetooth. " +
"Вы можете зайти в настройки, чтобы выдать это разрешение."
} else {
"Приложению необходимо разрешение для к сканированию по Bluetooth " +
"для работы с Bluetooth на устройствах с Android 11 и ниже"
}
}
}
class BluetoothConnectPermissionTextProvider : PermissionTextProvider {
override fun getDescription(isPermanentDeclined: Boolean): String {
return if (isPermanentDeclined) {
"Похоже вы навсегда запретили приложению доступ к подключению по Bluetooth." +
"Вы можете зайти в настройки, чтобы выдать это разрешение."
} else {
"Приложению необходимо разрешение для к подключению по Bluetooth." +
"для работы с Bluetooth на устройствах с Android 11 и ниже"
}
}
}
class BluetoothAdminPermissionTextProvider : PermissionTextProvider {
override fun getDescription(isPermanentDeclined: Boolean): String {
return if (isPermanentDeclined) {
"Похоже вы навсегда запретили приложению доступ к управлению настройками Bluetooth. " +
"Вы можете зайти в настройки, чтобы выдать это разрешение."
} else {
"Приложению необходимо разрешение для к управлению настройками Bluetooth " +
"для работы с Bluetooth на устройствах с Android 11 и ниже"
}
}
}
@@ -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) }
)
}
}
}
}
@@ -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*/ }
)
}
}
@@ -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 = {})
}
}
@@ -0,0 +1,138 @@
package com.helible.pilot.components.scannerScreen
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
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.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
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.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.dataclasses.BluetoothDevice
import com.helible.pilot.dataclasses.BluetoothUiState
@SuppressLint("MissingPermission")
@Composable
fun ScannerScreen(
bluetoothState: BluetoothUiState,
selectedDevice: BluetoothDevice?,
startScan: () -> Unit,
cancelScan: () -> Unit,
choiceDevice: (device: BluetoothDevice?) -> Unit,
onScreenChanged: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
) {
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
val (title, devicesList, controls) = createRefs()
Title(
text = "Устройства поблизости",
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp)
.constrainAs(title) {}
)
DiscoveredDevicesList(
bluetoothState = bluetoothState,
selectedDevice = selectedDevice,
choiceDevice = choiceDevice,
modifier = Modifier
.constrainAs(devicesList) {
top.linkTo(title.bottom)
bottom.linkTo(controls.top)
height = Dimension.fillToConstraints
}
)
Row(
modifier = Modifier
.padding(5.dp)
.constrainAs(controls) {
bottom.linkTo(parent.bottom)
width = Dimension.matchParent
height = Dimension.fillToConstraints
},
horizontalArrangement = Arrangement.Center
) {
FilledIconToggleButton(
checked = bluetoothState.isDiscovering,
onCheckedChange = {
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 (bluetoothState.isDiscovering) Icons.Filled.Close
else Icons.Filled.Refresh,
contentDescription = null
)
}
Button(
onClick = {
onScreenChanged()
},
modifier = Modifier
.align(Alignment.Bottom)
.padding(5.dp),
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],
{}, {},
{ _ -> },
{},
)
}
}
@@ -0,0 +1,24 @@
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,
fontSize: TextUnit = 23.sp,
) {
Text(
text = text,
textAlign = TextAlign.Center,
modifier = modifier,
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
}
@@ -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)
}
}
@@ -0,0 +1,69 @@
package com.helible.pilot.permissions
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList
import com.helible.pilot.components.BluetoothAdminPermissionTextProvider
import com.helible.pilot.components.BluetoothConnectPermissionTextProvider
import com.helible.pilot.components.BluetoothScanPermissionTextProvider
import com.helible.pilot.components.LocationPermissionTextProvider
import com.helible.pilot.components.PermissionDialog
@Composable
fun PermissionsRequest(
visiblePermissionDialogQueue: SnapshotStateList<String>,
dismissCurrentDialog: () -> Unit,
activity: Activity,
permissionLaunch: (permissions: Array<String>) -> Unit,
) {
/* Create Dialog windows, which requests all permissions */
visiblePermissionDialogQueue.reversed()
.forEach { permission ->
PermissionDialog(
permissionTextProvider = when (permission) {
Manifest.permission.ACCESS_FINE_LOCATION -> {
LocationPermissionTextProvider()
}
Manifest.permission.BLUETOOTH_SCAN -> {
BluetoothScanPermissionTextProvider()
}
Manifest.permission.BLUETOOTH_CONNECT -> {
BluetoothConnectPermissionTextProvider()
}
Manifest.permission.BLUETOOTH_ADMIN -> {
BluetoothAdminPermissionTextProvider()
}
else -> return@forEach
},
isPermanentDeclined = !activity.shouldShowRequestPermissionRationale(permission),
onDismiss = {
if (activity.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
dismissCurrentDialog()
},
onOkClick = {
dismissCurrentDialog()
permissionLaunch(arrayOf(permission))
},
onContinueClick = {
if (activity.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
dismissCurrentDialog()
},
onGoToAppSettingsClick = {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", activity.packageName, null)
)
activity.startActivity(intent)
}
)
}
}
@@ -0,0 +1,44 @@
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 com.helible.pilot.components.RequiredHardwareFeatures
import com.helible.pilot.dataclasses.BluetoothUiState
@SuppressLint("MissingPermission")
@Composable
fun RequestHardwareFeatures(
activity: Activity,
bluetoothUiState: BluetoothUiState,
) {
RequiredHardwareFeatures(
title = "Включите Bluetooth",
description = "Для работы приложения требуется Bluetooth",
confirmButtonText = "Включить Bluetooth",
featureState = bluetoothUiState.isEnabled,
requestFeature = {
val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
activity.startActivity(intent)
},
onDismissRequest = {}
)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
RequiredHardwareFeatures(
title = "Пожалуйста, включите геолокацию",
description = "Для работы с Bluetooth на устройствах с Android 11 и более ранних версиях, " +
"требуется геолокация.",
confirmButtonText = "Включить геолокацию",
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)
}
}
}
}
@@ -0,0 +1,11 @@
package com.helible.pilot.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,70 @@
package com.helible.pilot.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun TestblueTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,34 @@
package com.helible.pilot.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -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()
}
}
@@ -1,5 +0,0 @@
package com.listerk.helible
object AppState {
lateinit var bleDevice: BleDevice
}
@@ -1,3 +0,0 @@
package com.listerk.helible
data class BleDevice(val name: String, val address: String, var rssi: Int)
@@ -1,50 +0,0 @@
package com.listerk.helible
//import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
//import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView
//import org.w3c.dom.Text
class BleDeviceAdapter(private val devices: List<BleDevice>, private val gotoControlBtn: Button) :
RecyclerView.Adapter<BleDeviceAdapter.BleDeviceViewHolder>() {
private lateinit var itemView: View
private var selectedPosition = -1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BleDeviceViewHolder {
itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_ble_device, parent, false)
return BleDeviceViewHolder(itemView)
}
override fun getItemCount(): Int = devices.size
override fun onBindViewHolder(holder: BleDeviceViewHolder, position: Int) {
holder.nameView.text = devices[position].name
holder.addressView.text = devices[position].address
if (devices[position].rssi >= -80) holder.rssiView.setImageResource(R.drawable.signal_icon4)
else if (devices[position].rssi >= -90) holder.rssiView.setImageResource(R.drawable.signal_icon3)
else if (devices[position].rssi >= -100) holder.rssiView.setImageResource(R.drawable.signal_icon2)
else holder.rssiView.setImageResource(R.drawable.signal_icon1)
holder.cardView.isSelected = position == selectedPosition
holder.cardView.setOnClickListener {
AppState.bleDevice = devices[position]
gotoControlBtn.isEnabled = true
notifyItemChanged(selectedPosition)
selectedPosition = holder.adapterPosition
notifyItemChanged(selectedPosition)
}
}
class BleDeviceViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
val cardView: View = itemView.rootView
val nameView: TextView = itemView.findViewById(R.id.deviceName)
val addressView: TextView = itemView.findViewById(R.id.deviceAddress)
val rssiView: ImageView = itemView.findViewById(R.id.deviceRssi)
}
}
@@ -1,55 +0,0 @@
package com.listerk.helible
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.SeekBar
import android.widget.TextView
class ControlActivity : AppCompatActivity() {
private var sliderR1Value = 0
private var sliderR2Value = 0
private var sliderR3Value = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_control)
val deviceNameText = findViewById<TextView>(R.id.controlDeviceName)
deviceNameText.text = getString(R.string.control_device_text, AppState.bleDevice.name)
val sliderR1ValueText = findViewById<TextView>(R.id.sliderR1Value)
val sliderR2ValueText = findViewById<TextView>(R.id.sliderR2Value)
val sliderR3ValueText = findViewById<TextView>(R.id.sliderR3Value)
val sliderR1 = findViewById<SeekBar>(R.id.sliderR1)
val sliderR2 = findViewById<SeekBar>(R.id.sliderR2)
val sliderR3 = findViewById<SeekBar>(R.id.sliderR3)
sliderR1.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
sliderR1Value = progress
sliderR1ValueText.text = getString(R.string.r_slider_text, 1, progress)
}
override fun onStartTrackingTouch(p0: SeekBar?) {}
override fun onStopTrackingTouch(p0: SeekBar?) {}
})
sliderR2.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
sliderR2Value = progress
sliderR2ValueText.text = getString(R.string.r_slider_text, 2, progress)
}
override fun onStartTrackingTouch(p0: SeekBar?) {}
override fun onStopTrackingTouch(p0: SeekBar?) {}
})
sliderR3.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
sliderR3Value = progress
sliderR3ValueText.text = getString(R.string.r_slider_text, 3, progress)
}
override fun onStartTrackingTouch(p0: SeekBar?) {}
override fun onStopTrackingTouch(p0: SeekBar?) {}
})
}
}
@@ -1,140 +0,0 @@
package com.listerk.helible
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Animatable
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
//import android.os.Handler
import android.view.View
import android.widget.Button
import android.widget.ImageView
//import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@SuppressLint("MissingPermission")
class MainActivity : AppCompatActivity() {
private val bluetoothAdapter: BluetoothAdapter by lazy {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
bluetoothManager.adapter
}
private val bleScanner: BluetoothLeScanner by lazy {
bluetoothAdapter.bluetoothLeScanner
}
private val requiredPermissions: MutableSet<String> = mutableSetOf(Manifest.permission.BLUETOOTH_SCAN)
private val foundDevices = mutableListOf<BleDevice>()
private val leScanCallback: ScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
super.onScanResult(callbackType, result)
val bleDevicesView = findViewById<RecyclerView>(R.id.bleDevicesView)
val loader = findViewById<ImageView>(R.id.bleDevicesLoader)
if (result.device.name.isNullOrBlank()) {
return
}
if (loader.visibility == View.VISIBLE) {
(loader.drawable as? Animatable)?.stop()
loader.visibility = View.GONE
}
var deviceIndex: Int = foundDevices.indexOfFirst { it.address == result.device.address }
if (deviceIndex < 0) {
deviceIndex = foundDevices.size
foundDevices.add(BleDevice(result.device.name, result.device.address, result.rssi))
} else {
foundDevices[deviceIndex].rssi = result.rssi
}
bleDevicesView.adapter?.notifyItemChanged(deviceIndex)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val gotoControlButton = findViewById<Button>(R.id.gotoControlButton)
gotoControlButton.setOnClickListener {
val intent = Intent(this@MainActivity, ControlActivity::class.java)
startActivity(intent)
}
val loader = findViewById<ImageView>(R.id.bleDevicesLoader)
(loader.drawable as? Animatable)?.start()
val bleDevicesView = findViewById<RecyclerView>(R.id.bleDevicesView)
bleDevicesView.layoutManager = LinearLayoutManager(this)
bleDevicesView.adapter = BleDeviceAdapter(foundDevices, gotoControlButton)
bleDevicesView.itemAnimator = null
if (ActivityCompat.checkSelfPermission(this@MainActivity, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED)
{
Log.e("StartActivity", "We have denied permissions")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
{
ActivityCompat.requestPermissions(this@MainActivity, arrayOf(Manifest.permission.BLUETOOTH_CONNECT), 2)
return
}
}
/*if(Build.VERSION.SDK_INT < 31) {
requiredPermissions.add(android.Manifest.permission.ACCESS_FINE_LOCATION)
requiredPermissions.add(android.Manifest.permission.ACCESS_COARSE_LOCATION)
requiredPermissions.add(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
requestPermissions()*/
}
override fun onResume() {
super.onResume()
/*if (!hasNecessaryPermissions()) {
requestPermissions()
} else {*/
bleScanner.startScan(leScanCallback)
//}
}
override fun onPause() {
super.onPause()
/*if (!hasNecessaryPermissions()) {
requestPermissions()
} else {*/
bleScanner.stopScan(leScanCallback)
//}
}
private fun hasNecessaryPermissions(): Boolean
{
for (it in requiredPermissions) {
if (ActivityCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED) {
Log.i("ScanActivity", "Permission isn't allowed: $it")
return false
}
}
return true
}
private fun requestPermissions()
{
Log.i("ScanActivity", "Trying to request permissions: $requiredPermissions")
ActivityCompat.requestPermissions(this@MainActivity, requiredPermissions.toTypedArray(), 1)
if(!hasNecessaryPermissions())
{
Log.e("ScanActivity", "Failed to get all permissions")
} else {
Log.i("ScanActivity", "App got permissions: $requiredPermissions")
}
}
}
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape>
<solid android:color="?attr/selectedBlockColor" />
<corners android:radius="20dp"/>
</shape>
</item>
<item android:state_selected="false">
<shape>
<solid android:color="?attr/baseBlockColor" />
<corners android:radius="20dp"/>
</shape>
</item>
</selector>
+10
View 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
View 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>
@@ -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>
@@ -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
View 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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -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>
@@ -0,0 +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>
+10
View 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
View 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>
-72
View File
@@ -1,72 +0,0 @@
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:name="vector"
android:width="60dp"
android:height="15dp"
android:viewportWidth="40"
android:viewportHeight="10">
<path
android:name="path"
android:pathData="M 5 0 C 3.674 0 2.402 0.527 1.464 1.464 C 0.527 2.402 0 3.674 0 5 C 0 6.326 0.527 7.598 1.464 8.536 C 2.402 9.473 3.674 10 5 10 C 6.326 10 7.598 9.473 8.536 8.536 C 9.473 7.598 10 6.326 10 5 C 10 3.674 9.473 2.402 8.536 1.464 C 7.598 0.527 6.326 0 5 0 Z"
android:fillColor="?android:textColorPrimary"
android:fillAlpha="0"
android:strokeWidth="1"/>
<path
android:name="path_1"
android:pathData="M 20 0 C 18.674 0 17.402 0.527 16.464 1.464 C 15.527 2.402 15 3.674 15 5 C 15 6.326 15.527 7.598 16.464 8.536 C 17.402 9.473 18.674 10 20 10 C 21.326 10 22.598 9.473 23.536 8.536 C 24.473 7.598 25 6.326 25 5 C 25 3.674 24.473 2.402 23.536 1.464 C 22.598 0.527 21.326 0 20 0 Z"
android:fillColor="?android:textColorPrimary"
android:fillAlpha="0"
android:strokeWidth="1"/>
<path
android:name="path_2"
android:pathData="M 35 0 C 33.674 0 32.402 0.527 31.464 1.464 C 30.527 2.402 30 3.674 30 5 C 30 6.326 30.527 7.598 31.464 8.536 C 32.402 9.473 33.674 10 35 10 C 36.326 10 37.598 9.473 38.536 8.536 C 39.473 7.598 40 6.326 40 5 C 40 3.674 39.473 2.402 38.536 1.464 C 37.598 0.527 36.326 0 35 0 Z"
android:fillColor="?android:textColorPrimary"
android:fillAlpha="0"
android:strokeWidth="1"/>
</vector>
</aapt:attr>
<target android:name="path">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillAlpha"
android:duration="500"
android:valueFrom="0"
android:valueTo="1"
android:valueType="floatType"
android:repeatCount="infinite"
android:repeatMode="reverse"
android:interpolator="@android:anim/linear_interpolator"/>
</aapt:attr>
</target>
<target android:name="path_1">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillAlpha"
android:startOffset="200"
android:duration="500"
android:valueFrom="0"
android:valueTo="1"
android:valueType="floatType"
android:repeatCount="infinite"
android:repeatMode="reverse"
android:interpolator="@android:anim/linear_interpolator"/>
</aapt:attr>
</target>
<target android:name="path_2">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillAlpha"
android:startOffset="400"
android:duration="500"
android:valueFrom="0"
android:valueTo="1"
android:valueType="floatType"
android:repeatCount="infinite"
android:repeatMode="reverse"
android:interpolator="@android:anim/linear_interpolator"/>
</aapt:attr>
</target>
</animated-vector>
+11
View 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>
+8 -4
View File
@@ -1,15 +1,19 @@
<?xml version="1.0" encoding="utf-8"?> <?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 <path
android:fillColor="?android:colorPrimary" 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" /> 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" />
<path <path
android:fillColor="?android:colorButtonNormal" android:fillColor="?android:colorControlHighlight"
android:pathData="M 150 220 L 215 220 Q 230 220 230 235 L 230 485 Q 230 500 215 500 L 150 500 Q 135 500 135 485 L 135 235 Q 135 220 150 220 Z" /> android:pathData="M 150 220 L 215 220 Q 230 220 230 235 L 230 485 Q 230 500 215 500 L 150 500 Q 135 500 135 485 L 135 235 Q 135 220 150 220 Z" />
<path <path
android:fillColor="?android:colorButtonNormal" android:fillColor="?android:colorControlHighlight"
android:pathData="M 285 120 L 350 120 Q 365 120 365 135 L 365 485 Q 365 500 350 500 L 285 500 Q 270 500 270 485 L 270 135 Q 270 120 285 120 Z" /> android:pathData="M 285 120 L 350 120 Q 365 120 365 135 L 365 485 Q 365 500 350 500 L 285 500 Q 270 500 270 485 L 270 135 Q 270 120 285 120 Z" />
<path <path
android:fillColor="?android:colorButtonNormal" android:fillColor="?android:colorControlHighlight"
android:pathData="M 420 0 L 485 0 Q 500 0 500 15 L 500 485 Q 500 500 485 500 L 420 500 Q 405 500 405 485 L 405 15 Q 405 0 420 0 Z" /> android:pathData="M 420 0 L 485 0 Q 500 0 500 15 L 500 485 Q 500 500 485 500 L 420 500 Q 405 500 405 485 L 405 15 Q 405 0 420 0 Z" />
</vector> </vector>
+2 -2
View File
@@ -12,9 +12,9 @@
android:fillColor="?android:colorPrimary" android:fillColor="?android:colorPrimary"
android:pathData="M 150 220 L 215 220 Q 230 220 230 235 L 230 485 Q 230 500 215 500 L 150 500 Q 135 500 135 485 L 135 235 Q 135 220 150 220 Z" /> android:pathData="M 150 220 L 215 220 Q 230 220 230 235 L 230 485 Q 230 500 215 500 L 150 500 Q 135 500 135 485 L 135 235 Q 135 220 150 220 Z" />
<path <path
android:fillColor="?android:colorButtonNormal" android:fillColor="?android:colorControlHighlight"
android:pathData="M 285 120 L 350 120 Q 365 120 365 135 L 365 485 Q 365 500 350 500 L 285 500 Q 270 500 270 485 L 270 135 Q 270 120 285 120 Z" /> android:pathData="M 285 120 L 350 120 Q 365 120 365 135 L 365 485 Q 365 500 350 500 L 285 500 Q 270 500 270 485 L 270 135 Q 270 120 285 120 Z" />
<path <path
android:fillColor="?android:colorButtonNormal" android:fillColor="?android:colorControlHighlight"
android:pathData="M 420 0 L 485 0 Q 500 0 500 15 L 500 485 Q 500 500 485 500 L 420 500 Q 405 500 405 485 L 405 15 Q 405 0 420 0 Z" /> android:pathData="M 420 0 L 485 0 Q 500 0 500 15 L 500 485 Q 500 500 485 500 L 420 500 Q 405 500 405 485 L 405 15 Q 405 0 420 0 Z" />
</vector> </vector>
+1 -1
View File
@@ -15,6 +15,6 @@
android:fillColor="?android:colorPrimary" android:fillColor="?android:colorPrimary"
android:pathData="M 285 120 L 350 120 Q 365 120 365 135 L 365 485 Q 365 500 350 500 L 285 500 Q 270 500 270 485 L 270 135 Q 270 120 285 120 Z" /> android:pathData="M 285 120 L 350 120 Q 365 120 365 135 L 365 485 Q 365 500 350 500 L 285 500 Q 270 500 270 485 L 270 135 Q 270 120 285 120 Z" />
<path <path
android:fillColor="?android:colorButtonNormal" android:fillColor="?android:colorControlHighlight"
android:pathData="M 420 0 L 485 0 Q 500 0 500 15 L 500 485 Q 500 500 485 500 L 420 500 Q 405 500 405 485 L 405 15 Q 405 0 420 0 Z" /> android:pathData="M 420 0 L 485 0 Q 500 0 500 15 L 500 485 Q 500 500 485 500 L 420 500 Q 405 500 405 485 L 405 15 Q 405 0 420 0 Z" />
</vector> </vector>
@@ -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
View 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
View 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
View 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,73 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/controlDeviceName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Устройство не задано"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<SeekBar
android:id="@+id/sliderR1"
android:layout_width="300dp"
android:layout_height="wrap_content"
android:max="65535"
app:layout_constraintTop_toBottomOf="@+id/controlDeviceName"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/sliderR1Value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="R1: 0 / 65536"
app:layout_constraintTop_toBottomOf="@+id/sliderR1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<SeekBar
android:id="@+id/sliderR2"
android:layout_width="300dp"
android:layout_height="wrap_content"
android:max="65535"
app:layout_constraintTop_toBottomOf="@+id/sliderR1Value"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/sliderR2Value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="R2: 0 / 65536"
app:layout_constraintTop_toBottomOf="@+id/sliderR2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<SeekBar
android:id="@+id/sliderR3"
android:layout_width="300dp"
android:layout_height="wrap_content"
android:max="65535"
app:layout_constraintTop_toBottomOf="@+id/sliderR2Value"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/sliderR3Value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="R3: 0 / 65536"
app:layout_constraintTop_toBottomOf="@+id/sliderR3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
-53
View File
@@ -1,53 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ControlActivity">
<TextView
android:id="@+id/devicesTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="12dp"
android:textSize="24sp"
android:textStyle="bold"
android:text="Устройства" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/bleDevicesView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/devicesTitle"
tools:listitem="@layout/item_ble_device" />
<ImageView
android:id="@+id/bleDevicesLoader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/loader"
android:visibility="invisible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/bleDevicesView" />
<Button
android:id="@+id/gotoControlButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Далее"
android:enabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginBottom="12dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>

Some files were not shown because too many files have changed in this diff Show More