sforkowany z mirror/meshtastic-android
Merge pull request #12 from geeksville/decompose
Big changes to remove Compose and switch back to classic android1.2-legacy
commit
82f994ab9a
|
@ -16,7 +16,6 @@
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
<option name="resolveModulePerSourceSet" value="false" />
|
<option name="resolveModulePerSourceSet" value="false" />
|
||||||
<option name="useQualifiedModuleNames" value="true" />
|
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RenderSettings">
|
||||||
|
<option name="useLiveRendering" value="false" />
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -29,7 +29,7 @@ android {
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
// Enables Jetpack Compose for this module
|
// Enables Jetpack Compose for this module
|
||||||
compose true // NOTE, if true main app crashes if you use regular view layout functions
|
// compose true // NOTE, if true main app crashes if you use regular view layout functions
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set both the Java and Kotlin compilers to target Java 8.
|
// Set both the Java and Kotlin compilers to target Java 8.
|
||||||
|
@ -44,8 +44,8 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerVersion "1.3.61-dev-withExperimentalGoogleExtensions-20200129"
|
//kotlinCompilerVersion "1.3.61-dev-withExperimentalGoogleExtensions-20200129"
|
||||||
kotlinCompilerExtensionVersion "$compose_version"
|
//kotlinCompilerExtensionVersion "$compose_version"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,11 +73,17 @@ protobuf {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||||
implementation 'androidx.appcompat:appcompat:1.1.0'
|
implementation 'androidx.appcompat:appcompat:1.1.0'
|
||||||
implementation 'androidx.core:core-ktx:1.2.0'
|
implementation 'androidx.core:core-ktx:1.2.0'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
implementation "androidx.fragment:fragment-ktx:1.2.4"
|
||||||
implementation 'com.google.android.material:material:1.0.0'
|
implementation 'androidx.cardview:cardview:1.0.0'
|
||||||
|
implementation 'androidx.recyclerview:recyclerview:1.1.0'
|
||||||
|
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta4'
|
||||||
|
implementation 'com.google.android.material:material:1.1.0'
|
||||||
|
implementation 'androidx.viewpager2:viewpager2:1.0.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.1.0'
|
||||||
testImplementation 'junit:junit:4.12'
|
testImplementation 'junit:junit:4.12'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
|
||||||
|
@ -95,17 +101,6 @@ dependencies {
|
||||||
// mapbox
|
// mapbox
|
||||||
implementation 'com.mapbox.mapboxsdk:mapbox-android-sdk:9.0.0'
|
implementation 'com.mapbox.mapboxsdk:mapbox-android-sdk:9.0.0'
|
||||||
|
|
||||||
// You also need to include the following Compose toolkit dependencies.
|
|
||||||
implementation("androidx.compose:compose-runtime:$compose_version")
|
|
||||||
implementation("androidx.ui:ui-graphics:$compose_version")
|
|
||||||
implementation("androidx.ui:ui-layout:$compose_version")
|
|
||||||
implementation("androidx.ui:ui-material:$compose_version")
|
|
||||||
implementation("androidx.ui:ui-unit:$compose_version")
|
|
||||||
implementation("androidx.ui:ui-util:$compose_version")
|
|
||||||
implementation "androidx.ui:ui-tooling:$compose_version"
|
|
||||||
androidTestImplementation("androidx.ui:ui-platform:$compose_version")
|
|
||||||
androidTestImplementation("androidx.ui:ui-test:$compose_version")
|
|
||||||
|
|
||||||
// location services
|
// location services
|
||||||
implementation 'com.google.android.gms:play-services-location:17.0.0'
|
implementation 'com.google.android.gms:play-services-location:17.0.0'
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,9 @@
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<!-- the xing library will try to bring this permission in but we don't want it -->
|
<!-- the xing library will try to bring this permission in but we don't want it -->
|
||||||
<uses-permission android:name="android.permission.CAMERA" tools:node="remove" />
|
<uses-permission
|
||||||
|
android:name="android.permission.CAMERA"
|
||||||
|
tools:node="remove" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.bluetooth_le"
|
android:name="android.hardware.bluetooth_le"
|
||||||
|
@ -84,6 +86,7 @@
|
||||||
android:name="com.geeksville.mesh.MainActivity"
|
android:name="com.geeksville.mesh.MainActivity"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
|
android:windowSoftInputMode="stateAlwaysHidden"
|
||||||
android:theme="@style/AppTheme.NoActionBar">
|
android:theme="@style/AppTheme.NoActionBar">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
|
@ -1,104 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2020 The Android Open Source Project
|
|
||||||
*
|
|
||||||
* 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 androidx.ui.fakeandroidview
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.MotionEvent
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
|
||||||
import androidx.annotation.LayoutRes
|
|
||||||
import androidx.compose.Composable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composes an Android [View] given a layout resource [resId]. The method handles the inflation
|
|
||||||
* of the [View] and will call the [postInflationCallback] after this happens. Note that the
|
|
||||||
* callback will always be invoked on the main thread.
|
|
||||||
*
|
|
||||||
* @param resId The id of the layout resource to be inflated.
|
|
||||||
* @param postInflationCallback The callback to be invoked after the layout is inflated.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
// TODO(popam): support modifiers here
|
|
||||||
fun AndroidView(@LayoutRes resId: Int, postInflationCallback: (View) -> Unit = { _ -> }) {
|
|
||||||
AndroidViewHolder(postInflationCallback = postInflationCallback, resId = resId)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private class AndroidViewHolder(context: Context) : ViewGroup(context) {
|
|
||||||
var view: View? = null
|
|
||||||
set(value) {
|
|
||||||
if (value != field) {
|
|
||||||
field = value
|
|
||||||
removeAllViews()
|
|
||||||
addView(view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var postInflationCallback: (View) -> Unit = {}
|
|
||||||
|
|
||||||
var resId: Int? = null
|
|
||||||
set(value) {
|
|
||||||
if (value != field) {
|
|
||||||
field = value
|
|
||||||
val inflater = LayoutInflater.from(context)
|
|
||||||
val view = inflater.inflate(resId!!, this, false)
|
|
||||||
this.view = view
|
|
||||||
postInflationCallback(view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
isClickable = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
||||||
view?.measure(widthMeasureSpec, heightMeasureSpec)
|
|
||||||
setMeasuredDimension(view?.measuredWidth ?: 0, view?.measuredHeight ?: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
|
||||||
view?.layout(l, t, r, b)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getLayoutParams(): LayoutParams? {
|
|
||||||
return view?.layoutParams ?: LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Implement this method to handle touch screen motion events.
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* If this method is used to detect click actions, it is recommended that
|
|
||||||
* the actions be performed by implementing and calling
|
|
||||||
* [.performClick]. This will ensure consistent system behavior,
|
|
||||||
* including:
|
|
||||||
*
|
|
||||||
* * obeying click sound preferences
|
|
||||||
* * dispatching OnClickListener calls
|
|
||||||
* * handling [ACTION_CLICK][AccessibilityNodeInfo.ACTION_CLICK] when
|
|
||||||
* accessibility features are enabled
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* @param event The motion event.
|
|
||||||
* @return True if the event was handled, false otherwise.
|
|
||||||
*/
|
|
||||||
override fun onTouchEvent(event: MotionEvent?): Boolean {
|
|
||||||
return super.onTouchEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.geeksville.mesh
|
package com.geeksville.mesh
|
||||||
|
|
||||||
|
// import kotlinx.android.synthetic.main.tabs.*
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.bluetooth.BluetoothAdapter
|
import android.bluetooth.BluetoothAdapter
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
|
@ -15,29 +16,28 @@ import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.ui.core.setContent
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
import com.geeksville.android.Logging
|
import com.geeksville.android.Logging
|
||||||
import com.geeksville.android.ServiceClient
|
import com.geeksville.android.ServiceClient
|
||||||
import com.geeksville.mesh.model.MessagesState
|
|
||||||
import com.geeksville.mesh.model.NodeDB
|
|
||||||
import com.geeksville.mesh.model.TextMessage
|
import com.geeksville.mesh.model.TextMessage
|
||||||
import com.geeksville.mesh.model.UIState
|
import com.geeksville.mesh.model.UIViewModel
|
||||||
import com.geeksville.mesh.service.*
|
import com.geeksville.mesh.service.*
|
||||||
import com.geeksville.mesh.ui.AppStatus
|
import com.geeksville.mesh.ui.*
|
||||||
import com.geeksville.mesh.ui.MeshApp
|
|
||||||
import com.geeksville.mesh.ui.ScanState
|
|
||||||
import com.geeksville.mesh.ui.Screen
|
|
||||||
import com.geeksville.util.Exceptions
|
import com.geeksville.util.Exceptions
|
||||||
import com.geeksville.util.exceptionReporter
|
import com.geeksville.util.exceptionReporter
|
||||||
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
||||||
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
|
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
|
||||||
import com.google.android.gms.tasks.Task
|
import com.google.android.gms.tasks.Task
|
||||||
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
|
import kotlinx.android.synthetic.main.activity_main.*
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
UI design
|
UI design
|
||||||
|
|
||||||
|
@ -105,6 +105,55 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||||
bluetoothManager.adapter
|
bluetoothManager.adapter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val model: UIViewModel by viewModels()
|
||||||
|
|
||||||
|
data class TabInfo(val text: String, val icon: Int, val content: Fragment)
|
||||||
|
|
||||||
|
// private val tabIndexes = generateSequence(0) { it + 1 } FIXME, instead do withIndex or zip? to get the ids below, also stop duplicating strings
|
||||||
|
private val tabInfos = arrayOf(
|
||||||
|
TabInfo(
|
||||||
|
"Messages",
|
||||||
|
R.drawable.ic_twotone_message_24,
|
||||||
|
MessagesFragment()
|
||||||
|
),
|
||||||
|
TabInfo(
|
||||||
|
"Users",
|
||||||
|
R.drawable.ic_twotone_people_24,
|
||||||
|
UsersFragment()
|
||||||
|
),
|
||||||
|
TabInfo(
|
||||||
|
"Map",
|
||||||
|
R.drawable.ic_twotone_map_24,
|
||||||
|
MapFragment()
|
||||||
|
),
|
||||||
|
TabInfo(
|
||||||
|
"Channel",
|
||||||
|
R.drawable.ic_twotone_contactless_24,
|
||||||
|
ChannelFragment()
|
||||||
|
),
|
||||||
|
TabInfo(
|
||||||
|
"Settings",
|
||||||
|
R.drawable.ic_twotone_settings_applications_24,
|
||||||
|
SettingsFragment()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private
|
||||||
|
val tabsAdapter = object : FragmentStateAdapter(this) {
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = tabInfos.size
|
||||||
|
|
||||||
|
override fun createFragment(position: Int): Fragment {
|
||||||
|
// Return a NEW fragment instance in createFragment(int)
|
||||||
|
/*
|
||||||
|
fragment.arguments = Bundle().apply {
|
||||||
|
// Our object is just an integer :-P
|
||||||
|
putInt(ARG_OBJECT, position + 1)
|
||||||
|
} */
|
||||||
|
return tabInfos[position].content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun requestPermission() {
|
private fun requestPermission() {
|
||||||
debug("Checking permissions")
|
debug("Checking permissions")
|
||||||
|
|
||||||
|
@ -139,7 +188,11 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask for all the missing perms
|
// Ask for all the missing perms
|
||||||
ActivityCompat.requestPermissions(this, missingPerms.toTypedArray(), DID_REQUEST_PERM)
|
ActivityCompat.requestPermissions(
|
||||||
|
this,
|
||||||
|
missingPerms.toTypedArray(),
|
||||||
|
DID_REQUEST_PERM
|
||||||
|
)
|
||||||
|
|
||||||
// DID_REQUEST_PERM is an
|
// DID_REQUEST_PERM is an
|
||||||
// app-defined int constant. The callback method gets the
|
// app-defined int constant. The callback method gets the
|
||||||
|
@ -161,7 +214,7 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||||
|
|
||||||
private fun sendTestPackets() {
|
private fun sendTestPackets() {
|
||||||
exceptionReporter {
|
exceptionReporter {
|
||||||
val m = UIState.meshService!!
|
val m = model.meshService!!
|
||||||
|
|
||||||
// Do some test operations
|
// Do some test operations
|
||||||
val testPayload = "hello world".toByteArray()
|
val testPayload = "hello world".toByteArray()
|
||||||
|
@ -182,10 +235,8 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val prefs = UIState.getPreferences(this)
|
val prefs = UIViewModel.getPreferences(this)
|
||||||
UIState.ownerName = prefs.getString("owner", "")!!
|
model.ownerName.value = prefs.getString("owner", "")!!
|
||||||
UIState.meshService = null
|
|
||||||
UIState.savedInstanceState = savedInstanceState
|
|
||||||
|
|
||||||
// Ensures Bluetooth is available on the device and it is enabled. If not,
|
// Ensures Bluetooth is available on the device and it is enabled. If not,
|
||||||
// displays a dialog requesting user permission to enable Bluetooth.
|
// displays a dialog requesting user permission to enable Bluetooth.
|
||||||
|
@ -195,7 +246,11 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||||
startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
|
startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(this, "Error - this app requires bluetooth", Toast.LENGTH_LONG)
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
"Error - this app requires bluetooth",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -220,11 +275,32 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||||
// Handle any intent
|
// Handle any intent
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
|
|
||||||
setContent {
|
/* setContent {
|
||||||
MeshApp()
|
MeshApp()
|
||||||
}
|
} */
|
||||||
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
|
pager.adapter = tabsAdapter
|
||||||
|
pager.isUserInputEnabled =
|
||||||
|
false // Gestures for screen switching doesn't work so good with the map view
|
||||||
|
// pager.offscreenPageLimit = 0 // Don't keep any offscreen pages around, because we want to make sure our bluetooth scanning stops
|
||||||
|
TabLayoutMediator(tab_layout, pager) { tab, position ->
|
||||||
|
// tab.text = tabInfos[position].text // I think it looks better with icons only
|
||||||
|
tab.icon = getDrawable(tabInfos[position].icon)
|
||||||
|
}.attach()
|
||||||
|
|
||||||
|
model.isConnected.observe(this, Observer { connected ->
|
||||||
|
val image = when (connected) {
|
||||||
|
MeshService.ConnectionState.CONNECTED -> R.drawable.cloud_on
|
||||||
|
MeshService.ConnectionState.DEVICE_SLEEP -> R.drawable.ic_twotone_cloud_upload_24
|
||||||
|
MeshService.ConnectionState.DISCONNECTED -> R.drawable.cloud_off
|
||||||
|
}
|
||||||
|
|
||||||
|
connectStatusImage.setImageDrawable(getDrawable(image))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
|
@ -235,26 +311,26 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||||
val appLinkAction = intent.action
|
val appLinkAction = intent.action
|
||||||
val appLinkData: Uri? = intent.data
|
val appLinkData: Uri? = intent.data
|
||||||
|
|
||||||
UIState.requestedChannelUrl = null // assume none
|
|
||||||
|
|
||||||
// Were we asked to open one our channel URLs?
|
// Were we asked to open one our channel URLs?
|
||||||
if (Intent.ACTION_VIEW == appLinkAction) {
|
if (Intent.ACTION_VIEW == appLinkAction) {
|
||||||
debug("Asked to open a channel URL - FIXME, ask user if they want to switch to that channel. If so send the config to the radio")
|
debug("Asked to open a channel URL - FIXME, ask user if they want to switch to that channel. If so send the config to the radio")
|
||||||
UIState.requestedChannelUrl = appLinkData
|
val requestedChannelUrl = appLinkData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
unregisterMeshReceiver()
|
unregisterMeshReceiver()
|
||||||
UIState.meshService =
|
|
||||||
null // When our activity goes away make sure we don't keep a ptr around to the service
|
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch incoming result to the correct fragment.
|
* Dispatch incoming result to the correct fragment.
|
||||||
*/
|
*/
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
override fun onActivityResult(
|
||||||
|
requestCode: Int,
|
||||||
|
resultCode: Int,
|
||||||
|
data: Intent?
|
||||||
|
) {
|
||||||
super.onActivityResult(requestCode, resultCode, data)
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
|
||||||
// Result returned from launching the Intent from GoogleSignInClient.getSignInIntent(...);
|
// Result returned from launching the Intent from GoogleSignInClient.getSignInIntent(...);
|
||||||
|
@ -281,7 +357,8 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||||
} */
|
} */
|
||||||
}
|
}
|
||||||
|
|
||||||
private var receiverRegistered = false
|
private
|
||||||
|
var receiverRegistered = false
|
||||||
|
|
||||||
private fun registerMeshReceiver() {
|
private fun registerMeshReceiver() {
|
||||||
logAssert(!receiverRegistered)
|
logAssert(!receiverRegistered)
|
||||||
|
@ -300,40 +377,27 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the config bytes from the radio so we can show them in our GUI, the radio's copy is ground truth
|
|
||||||
private fun readRadioConfig() {
|
|
||||||
val bytes = UIState.meshService!!.radioConfig
|
|
||||||
|
|
||||||
val config = MeshProtos.RadioConfig.parseFrom(bytes)
|
|
||||||
UIState.setRadioConfig(this, config)
|
|
||||||
|
|
||||||
debug("Read config from radio")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called when we gain/lose a connection to our mesh radio
|
/// Called when we gain/lose a connection to our mesh radio
|
||||||
private fun onMeshConnectionChanged(connected: MeshService.ConnectionState) {
|
private fun onMeshConnectionChanged(connected: MeshService.ConnectionState) {
|
||||||
UIState.isConnected.value = connected
|
model.isConnected.value = connected
|
||||||
debug("connchange ${UIState.isConnected.value}")
|
debug("connchange ${model.isConnected.value}")
|
||||||
if (connected == MeshService.ConnectionState.CONNECTED) {
|
if (connected == MeshService.ConnectionState.CONNECTED) {
|
||||||
// always get the current radio config when we connect
|
|
||||||
readRadioConfig()
|
|
||||||
|
|
||||||
// everytime the radio reconnects, we slam in our current owner data, the radio is smart enough to only broadcast if needed
|
// everytime the radio reconnects, we slam in our current owner data, the radio is smart enough to only broadcast if needed
|
||||||
UIState.setOwner(this)
|
model.setOwner(this)
|
||||||
|
|
||||||
val m = UIState.meshService!!
|
val m = model.meshService!!
|
||||||
|
|
||||||
// Pull down our real node ID
|
// Pull down our real node ID
|
||||||
NodeDB.myId.value = m.myId
|
model.nodeDB.myId.value = m.myId
|
||||||
|
|
||||||
// Update our nodeinfos based on data from the device
|
// Update our nodeinfos based on data from the device
|
||||||
NodeDB.nodes.clear()
|
val nodes = m.nodes.map {
|
||||||
NodeDB.nodes.putAll(
|
it.user?.id!! to it
|
||||||
m.nodes.map
|
}.toMap()
|
||||||
{
|
|
||||||
it.user?.id!! to it
|
model.nodeDB.nodes.value = nodes
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -349,76 +413,95 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val meshServiceReceiver = object : BroadcastReceiver() {
|
private
|
||||||
|
val meshServiceReceiver = object : BroadcastReceiver() {
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) = exceptionReporter {
|
override fun onReceive(context: Context, intent: Intent) =
|
||||||
debug("Received from mesh service $intent")
|
exceptionReporter {
|
||||||
|
debug("Received from mesh service $intent")
|
||||||
|
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
MeshService.ACTION_NODE_CHANGE -> {
|
MeshService.ACTION_NODE_CHANGE -> {
|
||||||
val info: NodeInfo = intent.getParcelableExtra(EXTRA_NODEINFO)!!
|
val info: NodeInfo =
|
||||||
debug("UI nodechange $info")
|
intent.getParcelableExtra(EXTRA_NODEINFO)!!
|
||||||
|
debug("UI nodechange $info")
|
||||||
|
|
||||||
// We only care about nodes that have user info
|
// We only care about nodes that have user info
|
||||||
info.user?.id?.let {
|
info.user?.id?.let {
|
||||||
NodeDB.nodes[it] = info
|
val newnodes = model.nodeDB.nodes.value!! + Pair(it, info)
|
||||||
}
|
model.nodeDB.nodes.value = newnodes
|
||||||
}
|
|
||||||
|
|
||||||
MeshService.ACTION_RECEIVED_DATA -> {
|
|
||||||
debug("TODO rxdata")
|
|
||||||
val sender = intent.getStringExtra(EXTRA_SENDER)!!
|
|
||||||
val payload = intent.getByteArrayExtra(EXTRA_PAYLOAD)!!
|
|
||||||
val typ = intent.getIntExtra(EXTRA_TYP, -1)
|
|
||||||
|
|
||||||
when (typ) {
|
|
||||||
MeshProtos.Data.Type.CLEAR_TEXT_VALUE -> {
|
|
||||||
// FIXME - use the real time from the packet
|
|
||||||
// FIXME - don't just slam in a new list each time, it probably causes extra drawing. Figure out how to be Compose smarter...
|
|
||||||
val msg = TextMessage(sender, payload.toString(utf8))
|
|
||||||
|
|
||||||
MessagesState.addMessage(msg)
|
|
||||||
}
|
}
|
||||||
else -> TODO()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MeshService.ACTION_RECEIVED_DATA -> {
|
||||||
|
debug("TODO rxdata")
|
||||||
|
val sender =
|
||||||
|
intent.getStringExtra(EXTRA_SENDER)!!
|
||||||
|
val payload =
|
||||||
|
intent.getByteArrayExtra(EXTRA_PAYLOAD)!!
|
||||||
|
val typ = intent.getIntExtra(EXTRA_TYP, -1)
|
||||||
|
|
||||||
|
when (typ) {
|
||||||
|
MeshProtos.Data.Type.CLEAR_TEXT_VALUE -> {
|
||||||
|
// FIXME - use the real time from the packet
|
||||||
|
// FIXME - don't just slam in a new list each time, it probably causes extra drawing. Figure out how to be Compose smarter...
|
||||||
|
val msg = TextMessage(
|
||||||
|
sender,
|
||||||
|
payload.toString(utf8)
|
||||||
|
)
|
||||||
|
|
||||||
|
model.messagesState.addMessage(msg)
|
||||||
|
}
|
||||||
|
else -> TODO()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MeshService.ACTION_MESH_CONNECTED -> {
|
||||||
|
val connected =
|
||||||
|
MeshService.ConnectionState.valueOf(
|
||||||
|
intent.getStringExtra(
|
||||||
|
EXTRA_CONNECTED
|
||||||
|
)!!
|
||||||
|
)
|
||||||
|
onMeshConnectionChanged(connected)
|
||||||
|
}
|
||||||
|
else -> TODO()
|
||||||
}
|
}
|
||||||
MeshService.ACTION_MESH_CONNECTED -> {
|
|
||||||
val connected =
|
|
||||||
MeshService.ConnectionState.valueOf(intent.getStringExtra(EXTRA_CONNECTED)!!)
|
|
||||||
onMeshConnectionChanged(connected)
|
|
||||||
}
|
|
||||||
else -> TODO()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private val mesh = object : ServiceClient<com.geeksville.mesh.IMeshService>({
|
private
|
||||||
com.geeksville.mesh.IMeshService.Stub.asInterface(it)
|
val mesh = object :
|
||||||
}) {
|
ServiceClient<com.geeksville.mesh.IMeshService>({
|
||||||
override fun onConnected(service: com.geeksville.mesh.IMeshService) {
|
com.geeksville.mesh.IMeshService.Stub.asInterface(it)
|
||||||
UIState.meshService = service
|
}) {
|
||||||
|
override fun onConnected(service: com.geeksville.mesh.IMeshService) = exceptionReporter {
|
||||||
|
model.meshService = service
|
||||||
|
|
||||||
|
debug("Getting latest radioconfig from service")
|
||||||
|
model.radioConfig.value = MeshProtos.RadioConfig.parseFrom(service.radioConfig)
|
||||||
|
|
||||||
// We don't start listening for packets until after we are connected to the service
|
// We don't start listening for packets until after we are connected to the service
|
||||||
registerMeshReceiver()
|
registerMeshReceiver()
|
||||||
|
|
||||||
// We won't receive a notify for the initial state of connection, so we force an update here
|
// We won't receive a notify for the initial state of connection, so we force an update here
|
||||||
val connectionState = MeshService.ConnectionState.valueOf(service.connectionState())
|
val connectionState =
|
||||||
|
MeshService.ConnectionState.valueOf(service.connectionState())
|
||||||
onMeshConnectionChanged(connectionState)
|
onMeshConnectionChanged(connectionState)
|
||||||
|
|
||||||
debug("connected to mesh service, isConnected=${UIState.isConnected.value}")
|
debug("connected to mesh service, isConnected=${model.isConnected.value}")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDisconnected() {
|
override fun onDisconnected() {
|
||||||
unregisterMeshReceiver()
|
unregisterMeshReceiver()
|
||||||
UIState.meshService = null
|
model.meshService = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindMeshService() {
|
private fun bindMeshService() {
|
||||||
debug("Binding to mesh service!")
|
debug("Binding to mesh service!")
|
||||||
// we bind using the well known name, to make sure 3rd party apps could also
|
// we bind using the well known name, to make sure 3rd party apps could also
|
||||||
if (UIState.meshService != null)
|
if (model.meshService != null)
|
||||||
Exceptions.reportError("meshService was supposed to be null, ignoring (but reporting a bug)")
|
Exceptions.reportError("meshService was supposed to be null, ignoring (but reporting a bug)")
|
||||||
|
|
||||||
MeshService.startService(this)?.let { intent ->
|
MeshService.startService(this)?.let { intent ->
|
||||||
|
@ -433,11 +516,10 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||||
// if we never connected, do nothing
|
// if we never connected, do nothing
|
||||||
debug("Unbinding from mesh service!")
|
debug("Unbinding from mesh service!")
|
||||||
mesh.close()
|
mesh.close()
|
||||||
UIState.meshService = null
|
model.meshService = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
ScanState.stopScan()
|
|
||||||
unregisterMeshReceiver() // No point in receiving updates while the GUI is gone, we'll get them when the user launches the activity
|
unregisterMeshReceiver() // No point in receiving updates while the GUI is gone, we'll get them when the user launches the activity
|
||||||
unbindMeshService()
|
unbindMeshService()
|
||||||
|
|
||||||
|
@ -449,9 +531,12 @@ class MainActivity : AppCompatActivity(), Logging,
|
||||||
|
|
||||||
bindMeshService()
|
bindMeshService()
|
||||||
|
|
||||||
val bonded = RadioInterfaceService.getBondedDeviceAddress(this) != null
|
val bonded =
|
||||||
|
RadioInterfaceService.getBondedDeviceAddress(this) != null
|
||||||
|
/* FIXME - not yet working
|
||||||
if (!bonded)
|
if (!bonded)
|
||||||
AppStatus.currentScreen = Screen.settings
|
AppStatus.currentScreen = Screen.settings
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
|
|
|
@ -3,7 +3,6 @@ package com.geeksville.mesh.model
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import androidx.compose.Model
|
|
||||||
import com.geeksville.mesh.MeshProtos
|
import com.geeksville.mesh.MeshProtos
|
||||||
import com.google.zxing.BarcodeFormat
|
import com.google.zxing.BarcodeFormat
|
||||||
import com.google.zxing.MultiFormatWriter
|
import com.google.zxing.MultiFormatWriter
|
||||||
|
@ -11,7 +10,6 @@ import com.journeyapps.barcodescanner.BarcodeEncoder
|
||||||
import java.net.MalformedURLException
|
import java.net.MalformedURLException
|
||||||
|
|
||||||
|
|
||||||
@Model
|
|
||||||
data class Channel(
|
data class Channel(
|
||||||
var name: String,
|
var name: String,
|
||||||
var modemConfig: MeshProtos.ChannelSettings.ModemConfig,
|
var modemConfig: MeshProtos.ChannelSettings.ModemConfig,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package com.geeksville.mesh.model
|
package com.geeksville.mesh.model
|
||||||
|
|
||||||
import android.os.RemoteException
|
import android.os.RemoteException
|
||||||
import androidx.compose.frames.modelListOf
|
import androidx.lifecycle.MutableLiveData
|
||||||
import com.geeksville.android.BuildUtils.isEmulator
|
import com.geeksville.android.BuildUtils.isEmulator
|
||||||
import com.geeksville.android.Logging
|
import com.geeksville.android.Logging
|
||||||
import com.geeksville.mesh.MeshProtos
|
import com.geeksville.mesh.MeshProtos
|
||||||
|
@ -21,8 +21,8 @@ data class TextMessage(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
object MessagesState : Logging {
|
class MessagesState(private val ui: UIViewModel) : Logging {
|
||||||
private val testTexts = arrayOf(
|
private val testTexts = listOf(
|
||||||
TextMessage(
|
TextMessage(
|
||||||
"+16508765310",
|
"+16508765310",
|
||||||
"I found the cache"
|
"I found the cache"
|
||||||
|
@ -35,17 +35,20 @@ object MessagesState : Logging {
|
||||||
|
|
||||||
// If the following (unused otherwise) line is commented out, the IDE preview window works.
|
// If the following (unused otherwise) line is commented out, the IDE preview window works.
|
||||||
// if left in the preview always renders as empty.
|
// if left in the preview always renders as empty.
|
||||||
val messages = modelListOf(* if (isEmulator) testTexts else arrayOf())
|
val messages =
|
||||||
|
object : MutableLiveData<List<TextMessage>>(if (isEmulator) testTexts else listOf()) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
/// add a message our GUI list of past msgs
|
/// add a message our GUI list of past msgs
|
||||||
fun addMessage(m: TextMessage) {
|
fun addMessage(m: TextMessage) {
|
||||||
messages.add(m)
|
messages.value = messages.value!! + m
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a message and added it to our GUI log
|
/// Send a message and added it to our GUI log
|
||||||
fun sendMessage(str: String, dest: String? = null) {
|
fun sendMessage(str: String, dest: String? = null) {
|
||||||
var error: String? = null
|
var error: String? = null
|
||||||
val service = UIState.meshService
|
val service = ui.meshService
|
||||||
if (service != null)
|
if (service != null)
|
||||||
try {
|
try {
|
||||||
service.sendData(
|
service.sendData(
|
||||||
|
@ -59,9 +62,9 @@ object MessagesState : Logging {
|
||||||
else
|
else
|
||||||
error = "Error: No Mesh service"
|
error = "Error: No Mesh service"
|
||||||
|
|
||||||
MessagesState.addMessage(
|
addMessage(
|
||||||
TextMessage(
|
TextMessage(
|
||||||
NodeDB.myId.value,
|
ui.nodeDB.myId.value!!,
|
||||||
str,
|
str,
|
||||||
errorMessage = error
|
errorMessage = error
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
package com.geeksville.mesh.model
|
package com.geeksville.mesh.model
|
||||||
|
|
||||||
import androidx.compose.frames.modelMapOf
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.compose.mutableStateOf
|
|
||||||
import com.geeksville.android.BuildUtils.isEmulator
|
import com.geeksville.android.BuildUtils.isEmulator
|
||||||
import com.geeksville.mesh.MeshUser
|
import com.geeksville.mesh.MeshUser
|
||||||
import com.geeksville.mesh.NodeInfo
|
import com.geeksville.mesh.NodeInfo
|
||||||
import com.geeksville.mesh.Position
|
import com.geeksville.mesh.Position
|
||||||
|
|
||||||
object NodeDB {
|
|
||||||
|
/// NodeDB lives inside the UIViewModel, but it needs a backpointer to reach the service
|
||||||
|
class NodeDB(private val ui: UIViewModel) {
|
||||||
private val testPositions = arrayOf(
|
private val testPositions = arrayOf(
|
||||||
Position(32.776665, -96.796989, 35), // dallas
|
Position(32.776665, -96.796989, 35), // dallas
|
||||||
Position(32.960758, -96.733521, 35), // richardson
|
Position(32.960758, -96.733521, 35), // richardson
|
||||||
|
@ -43,12 +44,14 @@ object NodeDB {
|
||||||
private val seedWithTestNodes = isEmulator
|
private val seedWithTestNodes = isEmulator
|
||||||
|
|
||||||
/// The unique ID of our node
|
/// The unique ID of our node
|
||||||
val myId = mutableStateOf(if (isEmulator) "+16508765309" else "invalid")
|
val myId = object : MutableLiveData<String>(if (isEmulator) "+16508765309" else "invalid") {}
|
||||||
|
|
||||||
/// A map from nodeid to to nodeinfo
|
/// A map from nodeid to to nodeinfo
|
||||||
val nodes =
|
val nodes =
|
||||||
modelMapOf(* (if (isEmulator) testNodes else listOf()).map { it.user!!.id to it }.toTypedArray())
|
object :
|
||||||
|
MutableLiveData<Map<String, NodeInfo>>(mapOf(*(if (isEmulator) testNodes else listOf()).map { it.user!!.id to it }
|
||||||
|
.toTypedArray())) {}
|
||||||
|
|
||||||
/// Could be null if we haven't received our node DB yet
|
/// Could be null if we haven't received our node DB yet
|
||||||
val ourNodeInfo get() = nodes[myId.value]
|
val ourNodeInfo get() = nodes.value!![myId.value]
|
||||||
}
|
}
|
|
@ -3,72 +3,98 @@ package com.geeksville.mesh.model
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.RemoteException
|
import android.os.RemoteException
|
||||||
import androidx.compose.mutableStateOf
|
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
import com.geeksville.android.BuildUtils.isEmulator
|
import com.geeksville.android.BuildUtils.isEmulator
|
||||||
import com.geeksville.android.Logging
|
import com.geeksville.android.Logging
|
||||||
import com.geeksville.mesh.IMeshService
|
import com.geeksville.mesh.IMeshService
|
||||||
import com.geeksville.mesh.MeshProtos
|
import com.geeksville.mesh.MeshProtos
|
||||||
import com.geeksville.mesh.service.MeshService
|
import com.geeksville.mesh.service.MeshService
|
||||||
import com.geeksville.mesh.ui.getInitials
|
|
||||||
|
|
||||||
/// FIXME - figure out how to merge this staate with the AppStatus Model
|
/// Given a human name, strip out the first letter of the first three words and return that as the initials for
|
||||||
object UIState : Logging {
|
/// that user.
|
||||||
|
fun getInitials(name: String): String {
|
||||||
|
val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(3).map { it.first() }
|
||||||
|
.joinToString("")
|
||||||
|
|
||||||
|
return words
|
||||||
|
}
|
||||||
|
|
||||||
|
class UIViewModel : ViewModel(), Logging {
|
||||||
|
init {
|
||||||
|
debug("ViewModel created")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Return the current channel info
|
||||||
|
* FIXME, we should sim channels at the MeshService level if we are running on an emulator,
|
||||||
|
* for now I just fake it by returning a canned channel.
|
||||||
|
*/
|
||||||
|
fun getChannel(c: MeshProtos.RadioConfig?): Channel? {
|
||||||
|
val channel = c?.channelSettings?.let { Channel(it) }
|
||||||
|
|
||||||
|
return if (channel == null && isEmulator)
|
||||||
|
Channel.emulated
|
||||||
|
else
|
||||||
|
channel
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPreferences(context: Context): SharedPreferences =
|
||||||
|
context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
var meshService: IMeshService? = null
|
||||||
|
|
||||||
|
val nodeDB = NodeDB(this)
|
||||||
|
val messagesState = MessagesState(this)
|
||||||
|
|
||||||
|
/// Are we connected to our radio device
|
||||||
|
val isConnected =
|
||||||
|
object :
|
||||||
|
MutableLiveData<MeshService.ConnectionState>(MeshService.ConnectionState.DISCONNECTED) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// various radio settings (including the channel)
|
||||||
|
val radioConfig = object : MutableLiveData<MeshProtos.RadioConfig?>(null) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
debug("ViewModel cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the radio config (also updates our saved copy in preferences)
|
||||||
|
fun setRadioConfig(context: Context, c: MeshProtos.RadioConfig) {
|
||||||
|
debug("Setting new radio config!")
|
||||||
|
meshService?.radioConfig = c.toByteArray()
|
||||||
|
radioConfig.value = c
|
||||||
|
|
||||||
|
getPreferences(context).edit(commit = true) {
|
||||||
|
this.putString("channel-url", getChannel(c)!!.getChannelUrl().toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Kinda ugly - created in the activity but used from Compose - figure out if there is a cleaner way GIXME
|
/// Kinda ugly - created in the activity but used from Compose - figure out if there is a cleaner way GIXME
|
||||||
// lateinit var googleSignInClient: GoogleSignInClient
|
// lateinit var googleSignInClient: GoogleSignInClient
|
||||||
|
|
||||||
var meshService: IMeshService? = null
|
|
||||||
|
|
||||||
/// Are we connected to our radio device
|
|
||||||
val isConnected = mutableStateOf(MeshService.ConnectionState.DISCONNECTED)
|
|
||||||
|
|
||||||
/// various radio settings (including the channel)
|
|
||||||
private val radioConfig = mutableStateOf<MeshProtos.RadioConfig?>(null)
|
|
||||||
|
|
||||||
/// our name in hte radio
|
/// our name in hte radio
|
||||||
/// Note, we generate owner initials automatically for now
|
/// Note, we generate owner initials automatically for now
|
||||||
/// our activity will read this from prefs or set it to the empty string
|
/// our activity will read this from prefs or set it to the empty string
|
||||||
var ownerName: String = "MrInIDE Ownername"
|
val ownerName = object : MutableLiveData<String>("MrIDE Test") {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// If the app was launched because we received a new channel intent, the Url will be here
|
/// If the app was launched because we received a new channel intent, the Url will be here
|
||||||
var requestedChannelUrl: Uri? = null
|
var requestedChannelUrl: Uri? = null
|
||||||
|
|
||||||
var savedInstanceState: Bundle? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the current channel info
|
|
||||||
* FIXME, we should sim channels at the MeshService level if we are running on an emulator,
|
|
||||||
* for now I just fake it by returning a canned channel.
|
|
||||||
*/
|
|
||||||
fun getChannel(): Channel? {
|
|
||||||
val channel = radioConfig.value?.channelSettings?.let { Channel(it) }
|
|
||||||
|
|
||||||
return if (channel == null && isEmulator)
|
|
||||||
Channel.emulated
|
|
||||||
else
|
|
||||||
channel
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPreferences(context: Context): SharedPreferences =
|
|
||||||
context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
/// Set the radio config (also updates our saved copy in preferences)
|
|
||||||
fun setRadioConfig(context: Context, c: MeshProtos.RadioConfig) {
|
|
||||||
radioConfig.value = c
|
|
||||||
|
|
||||||
getPreferences(context).edit(commit = true) {
|
|
||||||
this.putString("channel-url", getChannel()!!.getChannelUrl().toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clean up all this nasty owner state management FIXME
|
// clean up all this nasty owner state management FIXME
|
||||||
fun setOwner(context: Context, s: String? = null) {
|
fun setOwner(context: Context, s: String? = null) {
|
||||||
|
|
||||||
if (s != null) {
|
if (s != null) {
|
||||||
ownerName = s
|
ownerName.value = s
|
||||||
|
|
||||||
// note: we allow an empty userstring to be written to prefs
|
// note: we allow an empty userstring to be written to prefs
|
||||||
getPreferences(context).edit(commit = true) {
|
getPreferences(context).edit(commit = true) {
|
||||||
|
@ -77,15 +103,16 @@ object UIState : Logging {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: we are careful to not set a new unique ID
|
// Note: we are careful to not set a new unique ID
|
||||||
if (ownerName.isNotEmpty())
|
if (ownerName.value!!.isNotEmpty())
|
||||||
try {
|
try {
|
||||||
meshService?.setOwner(
|
meshService?.setOwner(
|
||||||
null,
|
null,
|
||||||
ownerName,
|
ownerName.value,
|
||||||
getInitials(ownerName)
|
getInitials(ownerName.value!!)
|
||||||
) // Note: we use ?. here because we might be running in the emulator
|
) // Note: we use ?. here because we might be running in the emulator
|
||||||
} catch (ex: RemoteException) {
|
} catch (ex: RemoteException) {
|
||||||
errormsg("Can't set username on device, is device offline? ${ex.message}")
|
errormsg("Can't set username on device, is device offline? ${ex.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -316,7 +316,7 @@ class RadioInterfaceService : Service(), Logging {
|
||||||
debug("requested MTU result=$mtuRes")
|
debug("requested MTU result=$mtuRes")
|
||||||
mtuRes.getOrThrow() // FIXME - why sometimes is the result Unit!?!
|
mtuRes.getOrThrow() // FIXME - why sometimes is the result Unit!?!
|
||||||
|
|
||||||
fromNum = service.getCharacteristic(BTM_FROMNUM_CHARACTER)
|
fromNum = service.getCharacteristic(BTM_FROMNUM_CHARACTER)!!
|
||||||
|
|
||||||
// We must set this to true before broadcasting connectionChanged
|
// We must set this to true before broadcasting connectionChanged
|
||||||
isConnected = true
|
isConnected = true
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.compose.onCommit
|
|
||||||
import com.geeksville.android.GeeksvilleApplication
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track compose screen visibility
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun analyticsScreen(name: String) {
|
|
||||||
onCommit(AppStatus.currentScreen) {
|
|
||||||
GeeksvilleApplication.analytics.sendScreenView(name)
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
GeeksvilleApplication.analytics.endScreenView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,88 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.ui.core.DensityAmbient
|
|
||||||
import androidx.ui.core.DrawModifier
|
|
||||||
import androidx.ui.core.Modifier
|
|
||||||
import androidx.ui.core.asModifier
|
|
||||||
import androidx.ui.foundation.Box
|
|
||||||
import androidx.ui.graphics.*
|
|
||||||
import androidx.ui.graphics.colorspace.ColorSpaces
|
|
||||||
import androidx.ui.graphics.painter.ImagePainter
|
|
||||||
import androidx.ui.unit.Density
|
|
||||||
import androidx.ui.unit.PxSize
|
|
||||||
import androidx.ui.unit.toRect
|
|
||||||
|
|
||||||
/// Stolen from the Compose SimpleImage, replace with their real Image component someday
|
|
||||||
// TODO(mount, malkov) : remove when RepaintBoundary is a modifier: b/149982905
|
|
||||||
// This is class and not val because if b/149985596
|
|
||||||
private object ClipModifier : DrawModifier {
|
|
||||||
override fun draw(density: Density, drawContent: () -> Unit, canvas: Canvas, size: PxSize) {
|
|
||||||
canvas.save()
|
|
||||||
canvas.clipRect(size.toRect())
|
|
||||||
drawContent()
|
|
||||||
canvas.restore()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Stolen from the Compose SimpleImage, replace with their real Image component someday
|
|
||||||
@Composable
|
|
||||||
fun ScaledImage(
|
|
||||||
image: ImageAsset,
|
|
||||||
modifier: Modifier = Modifier.None,
|
|
||||||
tint: Color? = null
|
|
||||||
) {
|
|
||||||
with(DensityAmbient.current) {
|
|
||||||
val imageModifier = ImagePainter(image).asModifier(
|
|
||||||
scaleFit = ScaleFit.FillMaxDimension,
|
|
||||||
colorFilter = tint?.let { ColorFilter(it, BlendMode.srcIn) }
|
|
||||||
)
|
|
||||||
Box(modifier + ClipModifier + imageModifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Borrowed from Compose
|
|
||||||
class AndroidImage(val bitmap: Bitmap) : ImageAsset {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see Image.width
|
|
||||||
*/
|
|
||||||
override val width: Int
|
|
||||||
get() = bitmap.width
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see Image.height
|
|
||||||
*/
|
|
||||||
override val height: Int
|
|
||||||
get() = bitmap.height
|
|
||||||
|
|
||||||
override val config: ImageAssetConfig get() = ImageAssetConfig.Argb8888
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see Image.colorSpace
|
|
||||||
*/
|
|
||||||
override val colorSpace: androidx.ui.graphics.colorspace.ColorSpace
|
|
||||||
get() = ColorSpaces.Srgb
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see Image.hasAlpha
|
|
||||||
*/
|
|
||||||
override val hasAlpha: Boolean
|
|
||||||
get() = bitmap.hasAlpha()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see Image.nativeImage
|
|
||||||
*/
|
|
||||||
override val nativeImage: NativeImageAsset
|
|
||||||
get() = bitmap
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see
|
|
||||||
*/
|
|
||||||
override fun prepareToDraw() {
|
|
||||||
bitmap.prepareToDraw()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,114 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.ui.core.Modifier
|
|
||||||
import androidx.ui.foundation.Text
|
|
||||||
import androidx.ui.foundation.shape.corner.RoundedCornerShape
|
|
||||||
import androidx.ui.graphics.Color
|
|
||||||
import androidx.ui.layout.*
|
|
||||||
import androidx.ui.material.Divider
|
|
||||||
import androidx.ui.material.MaterialTheme
|
|
||||||
import androidx.ui.material.Surface
|
|
||||||
import androidx.ui.material.TextButton
|
|
||||||
import androidx.ui.tooling.preview.Preview
|
|
||||||
import androidx.ui.unit.dp
|
|
||||||
import com.geeksville.mesh.R
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AppDrawer(
|
|
||||||
currentScreen: ScreenInfo,
|
|
||||||
closeDrawer: () -> Unit
|
|
||||||
) {
|
|
||||||
Column(modifier = LayoutSize.Fill) {
|
|
||||||
Spacer(LayoutHeight(24.dp))
|
|
||||||
Row(modifier = LayoutPadding(16.dp)) {
|
|
||||||
VectorImage(
|
|
||||||
id = R.drawable.ic_launcher_new_foreground,
|
|
||||||
tint = MaterialTheme.colors.primary
|
|
||||||
)
|
|
||||||
Spacer(LayoutWidth(8.dp))
|
|
||||||
// VectorImage(id = R.drawable.ic_launcher_new_foreground)
|
|
||||||
}
|
|
||||||
Divider(color = Color(0x14333333))
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ScreenButton(screen: ScreenInfo) {
|
|
||||||
DrawerButton(
|
|
||||||
icon = screen.icon,
|
|
||||||
label = screen.label,
|
|
||||||
isSelected = currentScreen == screen
|
|
||||||
) {
|
|
||||||
navigateTo(screen)
|
|
||||||
closeDrawer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ScreenButton(Screen.messages)
|
|
||||||
ScreenButton(Screen.users)
|
|
||||||
ScreenButton(Screen.map) // turn off for now
|
|
||||||
ScreenButton(Screen.channel)
|
|
||||||
ScreenButton(Screen.settings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DrawerButton(
|
|
||||||
modifier: Modifier = Modifier.None,
|
|
||||||
@DrawableRes icon: Int,
|
|
||||||
label: String,
|
|
||||||
isSelected: Boolean,
|
|
||||||
action: () -> Unit
|
|
||||||
) {
|
|
||||||
val colors = MaterialTheme.colors
|
|
||||||
val textIconColor = if (isSelected) {
|
|
||||||
colors.primary
|
|
||||||
} else {
|
|
||||||
colors.onSurface.copy(alpha = 0.6f)
|
|
||||||
}
|
|
||||||
val backgroundColor = if (isSelected) {
|
|
||||||
colors.primary.copy(alpha = 0.12f)
|
|
||||||
} else {
|
|
||||||
colors.surface
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
modifier = modifier + LayoutPadding(
|
|
||||||
start = 8.dp,
|
|
||||||
top = 8.dp,
|
|
||||||
end = 8.dp,
|
|
||||||
bottom = 0.dp
|
|
||||||
),
|
|
||||||
color = backgroundColor,
|
|
||||||
shape = RoundedCornerShape(4.dp)
|
|
||||||
) {
|
|
||||||
TextButton(onClick = action) {
|
|
||||||
Row {
|
|
||||||
VectorImage(
|
|
||||||
modifier = LayoutGravity.Center,
|
|
||||||
id = icon,
|
|
||||||
tint = textIconColor
|
|
||||||
)
|
|
||||||
Spacer(LayoutWidth(16.dp))
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = (MaterialTheme.typography).body2.copy(
|
|
||||||
color = textIconColor
|
|
||||||
),
|
|
||||||
modifier = LayoutWidth.Fill
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun previewDrawer() {
|
|
||||||
AppDrawer(
|
|
||||||
currentScreen = AppStatus.currentScreen,
|
|
||||||
closeDrawer = { }
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,242 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.bluetooth.BluetoothManager
|
|
||||||
import android.bluetooth.le.*
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.os.ParcelUuid
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.compose.Model
|
|
||||||
import androidx.compose.frames.modelMapOf
|
|
||||||
import androidx.compose.onCommit
|
|
||||||
import androidx.ui.core.ContextAmbient
|
|
||||||
import androidx.ui.foundation.Text
|
|
||||||
import androidx.ui.layout.Column
|
|
||||||
import androidx.ui.layout.LayoutGravity
|
|
||||||
import androidx.ui.material.CircularProgressIndicator
|
|
||||||
import androidx.ui.material.MaterialTheme
|
|
||||||
import androidx.ui.material.ProvideEmphasis
|
|
||||||
import androidx.ui.material.RadioGroup
|
|
||||||
import androidx.ui.tooling.preview.Preview
|
|
||||||
import com.geeksville.android.Logging
|
|
||||||
import com.geeksville.mesh.service.RadioInterfaceService
|
|
||||||
import com.geeksville.util.exceptionReporter
|
|
||||||
|
|
||||||
|
|
||||||
@Model
|
|
||||||
object ScanUIState {
|
|
||||||
var selectedMacAddr: String? = null
|
|
||||||
var errorText: String? = null
|
|
||||||
|
|
||||||
val devices = modelMapOf<String, BTScanEntry>()
|
|
||||||
|
|
||||||
/// Change to a new macaddr selection, updating GUI and radio
|
|
||||||
fun changeSelection(context: Context, newAddr: String) {
|
|
||||||
ScanState.info("Changing BT device to $newAddr")
|
|
||||||
selectedMacAddr = newAddr
|
|
||||||
RadioInterfaceService.setBondedDeviceAddress(context, newAddr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FIXME, remove once compose has better lifecycle management
|
|
||||||
object ScanState : Logging {
|
|
||||||
var scanner: BluetoothLeScanner? = null
|
|
||||||
var callback: ScanCallback? = null // SUPER NASTY FIXME
|
|
||||||
|
|
||||||
fun stopScan() {
|
|
||||||
if (callback != null) {
|
|
||||||
debug("stopping scan")
|
|
||||||
try {
|
|
||||||
scanner!!.stopScan(callback)
|
|
||||||
} catch (ex: Throwable) {
|
|
||||||
warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}")
|
|
||||||
}
|
|
||||||
callback = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Model
|
|
||||||
data class BTScanEntry(val name: String, val macAddress: String, val bonded: Boolean) {
|
|
||||||
val isSelected get() = macAddress == ScanUIState.selectedMacAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BTScanScreen() {
|
|
||||||
val context = ContextAmbient.current
|
|
||||||
|
|
||||||
/// Note: may be null on platforms without a bluetooth driver (ie. the emulator)
|
|
||||||
val bluetoothAdapter =
|
|
||||||
(context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?)?.adapter
|
|
||||||
|
|
||||||
analyticsScreen(name = "settings")
|
|
||||||
onCommit(AppStatus.currentScreen) {
|
|
||||||
ScanState.debug("BTScan component active")
|
|
||||||
ScanUIState.selectedMacAddr = RadioInterfaceService.getBondedDeviceAddress(context)
|
|
||||||
|
|
||||||
val scanCallback = object : ScanCallback() {
|
|
||||||
override fun onScanFailed(errorCode: Int) {
|
|
||||||
val msg = "Unexpected bluetooth scan failure: $errorCode"
|
|
||||||
// error code2 seeems to be indicate hung bluetooth stack
|
|
||||||
ScanUIState.errorText = msg
|
|
||||||
ScanState.reportError(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For each device that appears in our scan, ask for its GATT, when the gatt arrives,
|
|
||||||
// check if it is an eligable device and store it in our list of candidates
|
|
||||||
// if that device later disconnects remove it as a candidate
|
|
||||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
|
||||||
|
|
||||||
val addr = result.device.address
|
|
||||||
// prevent logspam because weill get get lots of redundant scan results
|
|
||||||
val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED
|
|
||||||
val oldEntry = ScanUIState.devices[addr]
|
|
||||||
if (oldEntry == null || oldEntry.bonded != isBonded) {
|
|
||||||
val entry = BTScanEntry(
|
|
||||||
result.device.name,
|
|
||||||
addr,
|
|
||||||
isBonded
|
|
||||||
)
|
|
||||||
ScanState.debug("onScanResult ${entry}")
|
|
||||||
ScanUIState.devices[addr] = entry
|
|
||||||
|
|
||||||
// If nothing was selected, by default select the first thing we see
|
|
||||||
if (ScanUIState.selectedMacAddr == null && entry.bonded)
|
|
||||||
ScanUIState.changeSelection(context, addr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bluetoothAdapter == null) {
|
|
||||||
ScanState.warn("No bluetooth adapter. Running under emulation?")
|
|
||||||
|
|
||||||
val testnodes = listOf(
|
|
||||||
BTScanEntry("Meshtastic_ab12", "xx", false),
|
|
||||||
BTScanEntry("Meshtastic_32ac", "xb", true)
|
|
||||||
)
|
|
||||||
|
|
||||||
ScanUIState.devices.putAll(testnodes.map { it.macAddress to it })
|
|
||||||
|
|
||||||
// If nothing was selected, by default select the first thing we see
|
|
||||||
if (ScanUIState.selectedMacAddr == null)
|
|
||||||
ScanUIState.changeSelection(context, testnodes.first().macAddress)
|
|
||||||
} else {
|
|
||||||
/// The following call might return null if the user doesn't have bluetooth access permissions
|
|
||||||
val s: BluetoothLeScanner? = bluetoothAdapter.bluetoothLeScanner
|
|
||||||
|
|
||||||
if (s == null) {
|
|
||||||
ScanUIState.errorText =
|
|
||||||
"This application requires bluetooth access. Please grant access in android settings."
|
|
||||||
} else {
|
|
||||||
ScanState.debug("starting scan")
|
|
||||||
|
|
||||||
// filter and only accept devices that have a sw update service
|
|
||||||
val filter =
|
|
||||||
ScanFilter.Builder()
|
|
||||||
.setServiceUuid(ParcelUuid(RadioInterfaceService.BTM_SERVICE_UUID))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val settings =
|
|
||||||
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
|
|
||||||
s.startScan(listOf(filter), settings, scanCallback)
|
|
||||||
ScanState.scanner = s
|
|
||||||
ScanState.callback = scanCallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
ScanState.debug("BTScan component deactivated")
|
|
||||||
ScanState.stopScan()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
if (ScanUIState.errorText != null) {
|
|
||||||
Text(text = ScanUIState.errorText!!)
|
|
||||||
} else {
|
|
||||||
if (ScanUIState.devices.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = "Looking for Meshtastic devices... (zero found)",
|
|
||||||
modifier = LayoutGravity.Center
|
|
||||||
)
|
|
||||||
|
|
||||||
CircularProgressIndicator() // Show that we are searching still
|
|
||||||
} else {
|
|
||||||
// val allPaired = bluetoothAdapter?.bondedDevices.orEmpty().map { it.address }.toSet()
|
|
||||||
|
|
||||||
/* Only let user select paired devices
|
|
||||||
val paired = devices.values.filter { allPaired.contains(it.macAddress) }
|
|
||||||
if (paired.size < devices.size) {
|
|
||||||
Text(
|
|
||||||
"Warning: there are nearby Meshtastic devices that are not paired with this phone. Before you can select a device, you will need to pair it in Bluetooth Settings."
|
|
||||||
)
|
|
||||||
} */
|
|
||||||
|
|
||||||
RadioGroup {
|
|
||||||
Column {
|
|
||||||
ScanUIState.devices.values.forEach {
|
|
||||||
// disabled pending https://issuetracker.google.com/issues/149528535
|
|
||||||
ProvideEmphasis(emphasis = if (it.bonded) MaterialTheme.emphasisLevels.high else MaterialTheme.emphasisLevels.disabled) {
|
|
||||||
RadioGroupTextItem(
|
|
||||||
selected = (it.isSelected),
|
|
||||||
onSelect = {
|
|
||||||
// If the device is paired, let user select it, otherwise start the pairing flow
|
|
||||||
if (it.bonded) {
|
|
||||||
ScanUIState.changeSelection(context, it.macAddress)
|
|
||||||
} else {
|
|
||||||
ScanState.info("Starting bonding for $it")
|
|
||||||
|
|
||||||
// We need this receiver to get informed when the bond attempt finished
|
|
||||||
val bondChangedReceiver = object : BroadcastReceiver() {
|
|
||||||
|
|
||||||
override fun onReceive(
|
|
||||||
context: Context,
|
|
||||||
intent: Intent
|
|
||||||
) = exceptionReporter {
|
|
||||||
val state =
|
|
||||||
intent.getIntExtra(
|
|
||||||
BluetoothDevice.EXTRA_BOND_STATE,
|
|
||||||
-1
|
|
||||||
)
|
|
||||||
ScanState.debug("Received bond state changed $state")
|
|
||||||
context.unregisterReceiver(this)
|
|
||||||
if (state == BluetoothDevice.BOND_BONDED || state == BluetoothDevice.BOND_BONDING) {
|
|
||||||
ScanState.debug("Bonding completed, connecting service")
|
|
||||||
ScanUIState.changeSelection(
|
|
||||||
context,
|
|
||||||
it.macAddress
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val filter = IntentFilter()
|
|
||||||
filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
|
||||||
context.registerReceiver(bondChangedReceiver, filter)
|
|
||||||
|
|
||||||
// We ignore missing BT adapters, because it lets us run on the emulator
|
|
||||||
bluetoothAdapter
|
|
||||||
?.getRemoteDevice(it.macAddress)
|
|
||||||
?.createBond()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
text = it.name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun btScanScreenPreview() {
|
|
||||||
BTScanScreen()
|
|
||||||
}
|
|
|
@ -1,129 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.ui.core.ContextAmbient
|
|
||||||
import androidx.ui.foundation.Text
|
|
||||||
import androidx.ui.input.ImeAction
|
|
||||||
import androidx.ui.layout.*
|
|
||||||
import androidx.ui.material.MaterialTheme
|
|
||||||
import androidx.ui.material.OutlinedButton
|
|
||||||
import androidx.ui.tooling.preview.Preview
|
|
||||||
import androidx.ui.unit.dp
|
|
||||||
import com.geeksville.analytics.DataPair
|
|
||||||
import com.geeksville.android.GeeksvilleApplication
|
|
||||||
import com.geeksville.android.Logging
|
|
||||||
import com.geeksville.mesh.R
|
|
||||||
import com.geeksville.mesh.model.Channel
|
|
||||||
import com.geeksville.mesh.model.toHumanString
|
|
||||||
|
|
||||||
|
|
||||||
object ChannelLog : Logging
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChannelContent(channel: Channel?) {
|
|
||||||
analyticsScreen(name = "channel")
|
|
||||||
|
|
||||||
val typography = MaterialTheme.typography
|
|
||||||
val context = ContextAmbient.current
|
|
||||||
|
|
||||||
Column(modifier = LayoutSize.Fill + LayoutPadding(16.dp)) {
|
|
||||||
if (channel != null) {
|
|
||||||
Row(modifier = LayoutGravity.Center) {
|
|
||||||
|
|
||||||
Text(text = "Channel ", modifier = LayoutGravity.Center)
|
|
||||||
|
|
||||||
if (channel.editable) {
|
|
||||||
// FIXME - limit to max length
|
|
||||||
StyledTextField(
|
|
||||||
value = channel.name,
|
|
||||||
onValueChange = { channel.name = it },
|
|
||||||
textStyle = typography.h4.copy(
|
|
||||||
color = palette.onSecondary.copy(alpha = 0.8f)
|
|
||||||
),
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
onImeActionPerformed = {
|
|
||||||
ChannelLog.errormsg("FIXME, implement channel edit button")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = channel.name,
|
|
||||||
style = typography.h4
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// simulated qr code
|
|
||||||
// val image = imageResource(id = R.drawable.qrcode)
|
|
||||||
val image = AndroidImage(channel.getChannelQR())
|
|
||||||
|
|
||||||
ScaledImage(
|
|
||||||
image = image,
|
|
||||||
modifier = LayoutGravity.Center + LayoutSize.Min(200.dp, 200.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Mode: ${channel.modemConfig.toHumanString()}",
|
|
||||||
modifier = LayoutGravity.Center + LayoutPadding(bottom = 16.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(modifier = LayoutGravity.Center) {
|
|
||||||
|
|
||||||
OutlinedButton(onClick = {
|
|
||||||
channel.editable = !channel.editable
|
|
||||||
}) {
|
|
||||||
if (channel.editable)
|
|
||||||
VectorImage(
|
|
||||||
id = R.drawable.ic_twotone_lock_open_24,
|
|
||||||
tint = palette.onBackground
|
|
||||||
)
|
|
||||||
else
|
|
||||||
VectorImage(
|
|
||||||
id = R.drawable.ic_twotone_lock_24,
|
|
||||||
tint = palette.onBackground
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show the share buttone once we are locked
|
|
||||||
if (!channel.editable)
|
|
||||||
OutlinedButton(modifier = LayoutPadding(start = 24.dp),
|
|
||||||
onClick = {
|
|
||||||
GeeksvilleApplication.analytics.track(
|
|
||||||
"share",
|
|
||||||
DataPair("content_type", "channel")
|
|
||||||
) // track how many times users share channels
|
|
||||||
|
|
||||||
val sendIntent: Intent = Intent().apply {
|
|
||||||
action = Intent.ACTION_SEND
|
|
||||||
putExtra(Intent.EXTRA_TEXT, channel.getChannelUrl().toString())
|
|
||||||
putExtra(
|
|
||||||
Intent.EXTRA_TITLE,
|
|
||||||
"A URL for joining a Meshtastic mesh"
|
|
||||||
)
|
|
||||||
type = "text/plain"
|
|
||||||
}
|
|
||||||
|
|
||||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
|
||||||
context.startActivity(shareIntent)
|
|
||||||
}) {
|
|
||||||
VectorImage(
|
|
||||||
id = R.drawable.ic_twotone_share_24,
|
|
||||||
tint = palette.onBackground
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun previewChannel() {
|
|
||||||
// another bug? It seems modaldrawerlayout not yet supported in preview
|
|
||||||
MaterialTheme(colors = palette) {
|
|
||||||
ChannelContent(Channel.emulated)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,187 @@
|
||||||
|
package com.geeksville.mesh.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import com.geeksville.analytics.DataPair
|
||||||
|
import com.geeksville.android.GeeksvilleApplication
|
||||||
|
import com.geeksville.android.Logging
|
||||||
|
import com.geeksville.mesh.R
|
||||||
|
import com.geeksville.mesh.model.UIViewModel
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import kotlinx.android.synthetic.main.channel_fragment.*
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelFragment : ScreenFragment("Channel"), Logging {
|
||||||
|
|
||||||
|
private val model: UIViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
return inflater.inflate(R.layout.channel_fragment, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onEditingChanged() {
|
||||||
|
val isEditing = editableCheckbox.isChecked
|
||||||
|
|
||||||
|
channelOptions.isEnabled = false // Not yet ready
|
||||||
|
shareButton.isEnabled = !isEditing
|
||||||
|
channelNameView.isEnabled = isEditing
|
||||||
|
qrView.visibility =
|
||||||
|
if (isEditing) View.INVISIBLE else View.VISIBLE // Don't show the user a stale QR code
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
onEditingChanged() // Set initial state
|
||||||
|
|
||||||
|
editableCheckbox.setOnCheckedChangeListener { _, checked ->
|
||||||
|
onEditingChanged()
|
||||||
|
|
||||||
|
if (!checked) {
|
||||||
|
// User just locked it, we should warn and then apply changes to radio FIXME not ready yet
|
||||||
|
Snackbar.make(
|
||||||
|
editableCheckbox,
|
||||||
|
"Changing channels is not yet supported",
|
||||||
|
Snackbar.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model.radioConfig.observe(viewLifecycleOwner, Observer { config ->
|
||||||
|
val channel = UIViewModel.getChannel(config)
|
||||||
|
|
||||||
|
if (channel != null) {
|
||||||
|
qrView.visibility = View.VISIBLE
|
||||||
|
channelNameEdit.visibility = View.VISIBLE
|
||||||
|
channelNameEdit.setText(channel.name)
|
||||||
|
editableCheckbox.isEnabled = true
|
||||||
|
|
||||||
|
qrView.setImageBitmap(channel.getChannelQR())
|
||||||
|
// Share this particular channel if someone clicks share
|
||||||
|
shareButton.setOnClickListener {
|
||||||
|
GeeksvilleApplication.analytics.track(
|
||||||
|
"share",
|
||||||
|
DataPair("content_type", "channel")
|
||||||
|
) // track how many times users share channels
|
||||||
|
|
||||||
|
val sendIntent: Intent = Intent().apply {
|
||||||
|
action = Intent.ACTION_SEND
|
||||||
|
putExtra(Intent.EXTRA_TEXT, channel.getChannelUrl().toString())
|
||||||
|
putExtra(
|
||||||
|
Intent.EXTRA_TITLE,
|
||||||
|
"A URL for joining a Meshtastic mesh"
|
||||||
|
)
|
||||||
|
type = "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||||
|
requireActivity().startActivity(shareIntent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qrView.visibility = View.INVISIBLE
|
||||||
|
channelNameEdit.visibility = View.INVISIBLE
|
||||||
|
editableCheckbox.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val adapter = ArrayAdapter(
|
||||||
|
requireContext(),
|
||||||
|
R.layout.dropdown_menu_popup_item,
|
||||||
|
arrayOf("Item 1", "Item 2", "Item 3", "Item 4")
|
||||||
|
)
|
||||||
|
|
||||||
|
filled_exposed_dropdown.setAdapter(adapter)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
@Composable
|
||||||
|
fun ChannelContent(channel: Channel?) {
|
||||||
|
|
||||||
|
val typography = MaterialTheme.typography
|
||||||
|
val context = ContextAmbient.current
|
||||||
|
|
||||||
|
Column(modifier = LayoutSize.Fill + LayoutPadding(16.dp)) {
|
||||||
|
if (channel != null) {
|
||||||
|
Row(modifier = LayoutGravity.Center) {
|
||||||
|
|
||||||
|
Text(text = "Channel ", modifier = LayoutGravity.Center)
|
||||||
|
|
||||||
|
if (channel.editable) {
|
||||||
|
// FIXME - limit to max length
|
||||||
|
StyledTextField(
|
||||||
|
value = channel.name,
|
||||||
|
onValueChange = { channel.name = it },
|
||||||
|
textStyle = typography.h4.copy(
|
||||||
|
color = palette.onSecondary.copy(alpha = 0.8f)
|
||||||
|
),
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
onImeActionPerformed = {
|
||||||
|
ChannelLog.errormsg("FIXME, implement channel edit button")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = channel.name,
|
||||||
|
style = typography.h4
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// simulated qr code
|
||||||
|
// val image = imageResource(id = R.drawable.qrcode)
|
||||||
|
val image = AndroidImage(channel.getChannelQR())
|
||||||
|
|
||||||
|
ScaledImage(
|
||||||
|
image = image,
|
||||||
|
modifier = LayoutGravity.Center + LayoutSize.Min(200.dp, 200.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Mode: ${channel.modemConfig.toHumanString()}",
|
||||||
|
modifier = LayoutGravity.Center + LayoutPadding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(modifier = LayoutGravity.Center) {
|
||||||
|
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
channel.editable = !channel.editable
|
||||||
|
}) {
|
||||||
|
if (channel.editable)
|
||||||
|
VectorImage(
|
||||||
|
id = R.drawable.ic_twotone_lock_open_24,
|
||||||
|
tint = palette.onBackground
|
||||||
|
)
|
||||||
|
else
|
||||||
|
VectorImage(
|
||||||
|
id = R.drawable.ic_twotone_lock_24,
|
||||||
|
tint = palette.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show the share buttone once we are locked
|
||||||
|
if (!channel.editable)
|
||||||
|
OutlinedButton(modifier = LayoutPadding(start = 24.dp),
|
||||||
|
onClick = {
|
||||||
|
|
||||||
|
}) {
|
||||||
|
VectorImage(
|
||||||
|
id = R.drawable.ic_twotone_share_24,
|
||||||
|
tint = palette.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
|
@ -1,187 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.app.Application
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.compose.onCommit
|
|
||||||
import androidx.ui.core.ContextAmbient
|
|
||||||
import androidx.ui.fakeandroidview.AndroidView
|
|
||||||
import androidx.ui.material.MaterialTheme
|
|
||||||
import androidx.ui.tooling.preview.Preview
|
|
||||||
import com.geeksville.android.Logging
|
|
||||||
import com.geeksville.mesh.R
|
|
||||||
import com.geeksville.mesh.model.NodeDB
|
|
||||||
import com.geeksville.mesh.model.UIState
|
|
||||||
import com.mapbox.geojson.Feature
|
|
||||||
import com.mapbox.geojson.FeatureCollection
|
|
||||||
import com.mapbox.geojson.Point
|
|
||||||
import com.mapbox.mapboxsdk.camera.CameraPosition
|
|
||||||
import com.mapbox.mapboxsdk.camera.CameraUpdateFactory
|
|
||||||
import com.mapbox.mapboxsdk.geometry.LatLng
|
|
||||||
import com.mapbox.mapboxsdk.geometry.LatLngBounds
|
|
||||||
import com.mapbox.mapboxsdk.maps.MapView
|
|
||||||
import com.mapbox.mapboxsdk.maps.Style
|
|
||||||
import com.mapbox.mapboxsdk.style.expressions.Expression
|
|
||||||
import com.mapbox.mapboxsdk.style.layers.Property
|
|
||||||
import com.mapbox.mapboxsdk.style.layers.Property.TEXT_ANCHOR_TOP
|
|
||||||
import com.mapbox.mapboxsdk.style.layers.Property.TEXT_JUSTIFY_AUTO
|
|
||||||
import com.mapbox.mapboxsdk.style.layers.PropertyFactory.*
|
|
||||||
import com.mapbox.mapboxsdk.style.layers.SymbolLayer
|
|
||||||
import com.mapbox.mapboxsdk.style.sources.GeoJsonSource
|
|
||||||
|
|
||||||
|
|
||||||
object mapLog : Logging
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* mapbox requires this, until compose has a nicer way of doing it, do it here
|
|
||||||
*/
|
|
||||||
private val mapLifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
|
|
||||||
var view: MapView? = null
|
|
||||||
|
|
||||||
override fun onActivityPaused(activity: Activity) {
|
|
||||||
view?.onPause()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityStarted(activity: Activity) {
|
|
||||||
view?.onStart()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityDestroyed(activity: Activity) {
|
|
||||||
view?.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
|
|
||||||
view?.onSaveInstanceState(outState)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityStopped(activity: Activity) {
|
|
||||||
view?.onStop()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the Activity calls [super.onCreate()][Activity.onCreate].
|
|
||||||
*/
|
|
||||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityResumed(activity: Activity) {
|
|
||||||
view?.onResume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MapContent() {
|
|
||||||
analyticsScreen(name = "map")
|
|
||||||
|
|
||||||
val context = ContextAmbient.current
|
|
||||||
|
|
||||||
onCommit(AppStatus.currentScreen) {
|
|
||||||
onDispose {
|
|
||||||
// We no longer care about activity lifecycle
|
|
||||||
(context.applicationContext as Application).unregisterActivityLifecycleCallbacks(
|
|
||||||
mapLifecycleCallbacks
|
|
||||||
)
|
|
||||||
mapLifecycleCallbacks.view = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all nodes with valid locations
|
|
||||||
val nodesWithPosition = NodeDB.nodes.values.filter { it.validPosition != null }
|
|
||||||
val locations = nodesWithPosition.map { node ->
|
|
||||||
val p = node.position!!
|
|
||||||
mapLog.debug("Showing on map: $node")
|
|
||||||
|
|
||||||
val f = Feature.fromGeometry(
|
|
||||||
Point.fromLngLat(
|
|
||||||
p.longitude,
|
|
||||||
p.latitude
|
|
||||||
)
|
|
||||||
)
|
|
||||||
node.user?.let {
|
|
||||||
f.addStringProperty("name", it.longName)
|
|
||||||
}
|
|
||||||
f
|
|
||||||
}
|
|
||||||
val nodeSourceId = "node-positions"
|
|
||||||
val nodeLayerId = "node-layer"
|
|
||||||
val labelLayerId = "label-layer"
|
|
||||||
val markerImageId = "my-marker-image"
|
|
||||||
val nodePositions =
|
|
||||||
GeoJsonSource(nodeSourceId, FeatureCollection.fromFeatures(locations))
|
|
||||||
|
|
||||||
// val markerIcon = BitmapFactory.decodeResource(context.resources, R.drawable.ic_twotone_person_pin_24)
|
|
||||||
val markerIcon = context.getDrawable(R.drawable.ic_twotone_person_pin_24)!!
|
|
||||||
|
|
||||||
val nodeLayer = SymbolLayer(nodeLayerId, nodeSourceId).withProperties(
|
|
||||||
iconImage(markerImageId),
|
|
||||||
iconAnchor(Property.ICON_ANCHOR_BOTTOM),
|
|
||||||
iconAllowOverlap(true)
|
|
||||||
)
|
|
||||||
|
|
||||||
val labelLayer = SymbolLayer(labelLayerId, nodeSourceId).withProperties(
|
|
||||||
textField(Expression.get("name")),
|
|
||||||
textSize(12f),
|
|
||||||
textColor(Color.RED),
|
|
||||||
textVariableAnchor(arrayOf(TEXT_ANCHOR_TOP)),
|
|
||||||
textJustify(TEXT_JUSTIFY_AUTO),
|
|
||||||
textAllowOverlap(true)
|
|
||||||
)
|
|
||||||
|
|
||||||
AndroidView(R.layout.map_view) { view ->
|
|
||||||
view as MapView
|
|
||||||
view.onCreate(UIState.savedInstanceState)
|
|
||||||
|
|
||||||
mapLifecycleCallbacks.view = view
|
|
||||||
(context.applicationContext as Application).registerActivityLifecycleCallbacks(
|
|
||||||
mapLifecycleCallbacks
|
|
||||||
)
|
|
||||||
|
|
||||||
view.getMapAsync { map ->
|
|
||||||
map.setStyle(Style.OUTDOORS) { style ->
|
|
||||||
style.addSource(nodePositions)
|
|
||||||
style.addImage(markerImageId, markerIcon)
|
|
||||||
style.addLayer(nodeLayer)
|
|
||||||
style.addLayer(labelLayer)
|
|
||||||
}
|
|
||||||
|
|
||||||
//map.uiSettings.isScrollGesturesEnabled = true
|
|
||||||
//map.uiSettings.isZoomGesturesEnabled = true
|
|
||||||
|
|
||||||
if (nodesWithPosition.isNotEmpty()) {
|
|
||||||
val update = if (nodesWithPosition.size >= 2) {
|
|
||||||
// Multiple nodes, make them all fit on the map view
|
|
||||||
val bounds = LatLngBounds.Builder()
|
|
||||||
|
|
||||||
// Add all positions
|
|
||||||
bounds.includes(nodesWithPosition.map { it.position!! }
|
|
||||||
.map { LatLng(it.latitude, it.longitude) })
|
|
||||||
|
|
||||||
CameraUpdateFactory.newLatLngBounds(bounds.build(), 150)
|
|
||||||
} else {
|
|
||||||
// Only one node, just zoom in on it
|
|
||||||
val it = nodesWithPosition[0].position!!
|
|
||||||
|
|
||||||
val cameraPos = CameraPosition.Builder().target(
|
|
||||||
LatLng(it.latitude, it.longitude)
|
|
||||||
).zoom(9.0).build()
|
|
||||||
CameraUpdateFactory.newCameraPosition(cameraPos)
|
|
||||||
}
|
|
||||||
map.animateCamera(update, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun previewMap() {
|
|
||||||
// another bug? It seems modaldrawerlayout not yet supported in preview
|
|
||||||
MaterialTheme(colors = palette) {
|
|
||||||
MapContent()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,183 @@
|
||||||
|
package com.geeksville.mesh.ui
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import com.geeksville.android.Logging
|
||||||
|
import com.geeksville.mesh.NodeInfo
|
||||||
|
import com.geeksville.mesh.R
|
||||||
|
import com.geeksville.mesh.model.UIViewModel
|
||||||
|
import com.mapbox.geojson.Feature
|
||||||
|
import com.mapbox.geojson.FeatureCollection
|
||||||
|
import com.mapbox.geojson.Point
|
||||||
|
import com.mapbox.mapboxsdk.camera.CameraPosition
|
||||||
|
import com.mapbox.mapboxsdk.camera.CameraUpdateFactory
|
||||||
|
import com.mapbox.mapboxsdk.geometry.LatLng
|
||||||
|
import com.mapbox.mapboxsdk.geometry.LatLngBounds
|
||||||
|
import com.mapbox.mapboxsdk.maps.MapView
|
||||||
|
import com.mapbox.mapboxsdk.maps.MapboxMap
|
||||||
|
import com.mapbox.mapboxsdk.maps.Style
|
||||||
|
import com.mapbox.mapboxsdk.style.expressions.Expression
|
||||||
|
import com.mapbox.mapboxsdk.style.layers.Property
|
||||||
|
import com.mapbox.mapboxsdk.style.layers.Property.TEXT_ANCHOR_TOP
|
||||||
|
import com.mapbox.mapboxsdk.style.layers.Property.TEXT_JUSTIFY_AUTO
|
||||||
|
import com.mapbox.mapboxsdk.style.layers.PropertyFactory.*
|
||||||
|
import com.mapbox.mapboxsdk.style.layers.SymbolLayer
|
||||||
|
import com.mapbox.mapboxsdk.style.sources.GeoJsonSource
|
||||||
|
|
||||||
|
|
||||||
|
class MapFragment : ScreenFragment("Map"), Logging {
|
||||||
|
|
||||||
|
private val model: UIViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private val nodeSourceId = "node-positions"
|
||||||
|
private val nodeLayerId = "node-layer"
|
||||||
|
private val labelLayerId = "label-layer"
|
||||||
|
private val markerImageId = "my-marker-image"
|
||||||
|
|
||||||
|
private val nodePositions = GeoJsonSource(nodeSourceId)
|
||||||
|
|
||||||
|
private val nodeLayer = SymbolLayer(nodeLayerId, nodeSourceId).withProperties(
|
||||||
|
iconImage(markerImageId),
|
||||||
|
iconAnchor(Property.ICON_ANCHOR_BOTTOM),
|
||||||
|
iconAllowOverlap(true)
|
||||||
|
)
|
||||||
|
|
||||||
|
private val labelLayer = SymbolLayer(labelLayerId, nodeSourceId).withProperties(
|
||||||
|
textField(Expression.get("name")),
|
||||||
|
textSize(12f),
|
||||||
|
textColor(Color.RED),
|
||||||
|
textVariableAnchor(arrayOf(TEXT_ANCHOR_TOP)),
|
||||||
|
textJustify(TEXT_JUSTIFY_AUTO),
|
||||||
|
textAllowOverlap(true)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
private fun onNodesChanged(map: MapboxMap, nodes: Collection<NodeInfo>) {
|
||||||
|
val nodesWithPosition = nodes.filter { it.validPosition != null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Using the latest nodedb, generate geojson features
|
||||||
|
*/
|
||||||
|
fun getCurrentNodes(): FeatureCollection {
|
||||||
|
// Find all nodes with valid locations
|
||||||
|
|
||||||
|
val locations = nodesWithPosition.map { node ->
|
||||||
|
val p = node.position!!
|
||||||
|
debug("Showing on map: $node")
|
||||||
|
|
||||||
|
val f = Feature.fromGeometry(
|
||||||
|
Point.fromLngLat(
|
||||||
|
p.longitude,
|
||||||
|
p.latitude
|
||||||
|
)
|
||||||
|
)
|
||||||
|
node.user?.let {
|
||||||
|
f.addStringProperty("name", it.longName)
|
||||||
|
}
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
return FeatureCollection.fromFeatures(locations)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun zoomToNodes(map: MapboxMap) {
|
||||||
|
if (nodesWithPosition.isNotEmpty()) {
|
||||||
|
val update = if (nodesWithPosition.size >= 2) {
|
||||||
|
// Multiple nodes, make them all fit on the map view
|
||||||
|
val bounds = LatLngBounds.Builder()
|
||||||
|
|
||||||
|
// Add all positions
|
||||||
|
bounds.includes(nodes.map { it.position!! }
|
||||||
|
.map { LatLng(it.latitude, it.longitude) })
|
||||||
|
|
||||||
|
CameraUpdateFactory.newLatLngBounds(bounds.build(), 150)
|
||||||
|
} else {
|
||||||
|
// Only one node, just zoom in on it
|
||||||
|
val it = nodesWithPosition[0].position!!
|
||||||
|
|
||||||
|
val cameraPos = CameraPosition.Builder().target(
|
||||||
|
LatLng(it.latitude, it.longitude)
|
||||||
|
).zoom(9.0).build()
|
||||||
|
CameraUpdateFactory.newCameraPosition(cameraPos)
|
||||||
|
}
|
||||||
|
map.animateCamera(update, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodePositions.setGeoJson(getCurrentNodes()) // Update node positions
|
||||||
|
zoomToNodes(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? = inflater.inflate(R.layout.map_view, container, false)
|
||||||
|
|
||||||
|
lateinit var mapView: MapView
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
mapView = view.findViewById(R.id.mapView)
|
||||||
|
mapView.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
mapView.getMapAsync { map ->
|
||||||
|
|
||||||
|
// val markerIcon = BitmapFactory.decodeResource(context.resources, R.drawable.ic_twotone_person_pin_24)
|
||||||
|
val markerIcon = requireActivity().getDrawable(R.drawable.ic_twotone_person_pin_24)!!
|
||||||
|
|
||||||
|
map.setStyle(Style.OUTDOORS) { style ->
|
||||||
|
style.addSource(nodePositions)
|
||||||
|
style.addImage(markerImageId, markerIcon)
|
||||||
|
style.addLayer(nodeLayer)
|
||||||
|
style.addLayer(labelLayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
model.nodeDB.nodes.observe(viewLifecycleOwner, Observer { nodes ->
|
||||||
|
onNodesChanged(map, nodes.values)
|
||||||
|
})
|
||||||
|
|
||||||
|
//map.uiSettings.isScrollGesturesEnabled = true
|
||||||
|
//map.uiSettings.isZoomGesturesEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
mapView.onPause()
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
mapView.onStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
mapView.onStop()
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
mapView.onResume()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
mapView.onDestroy()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
mapView.onSaveInstanceState(outState)
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,19 +1,13 @@
|
||||||
package com.geeksville.mesh.ui
|
package com.geeksville.mesh.ui
|
||||||
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.compose.state
|
|
||||||
import androidx.ui.foundation.Text
|
|
||||||
import androidx.ui.material.*
|
|
||||||
import androidx.ui.tooling.preview.Preview
|
|
||||||
import com.geeksville.android.Logging
|
import com.geeksville.android.Logging
|
||||||
import com.geeksville.mesh.R
|
|
||||||
import com.geeksville.mesh.model.UIState
|
|
||||||
|
|
||||||
|
|
||||||
object UILog : Logging
|
object UILog : Logging
|
||||||
|
/*
|
||||||
val palette = lightColorPalette() // darkColorPalette()
|
val palette = lightColorPalette() // darkColorPalette()
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MeshApp() {
|
fun MeshApp() {
|
||||||
val (drawerState, onDrawerStateChange) = state { DrawerState.Closed }
|
val (drawerState, onDrawerStateChange) = state { DrawerState.Closed }
|
||||||
|
@ -73,3 +67,4 @@ private fun AppContent(openDrawer: () -> Unit) {
|
||||||
}
|
}
|
||||||
//}
|
//}
|
||||||
}
|
}
|
||||||
|
*/
|
|
@ -1,124 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.compose.state
|
|
||||||
import androidx.ui.core.Modifier
|
|
||||||
import androidx.ui.foundation.Text
|
|
||||||
import androidx.ui.foundation.VerticalScroller
|
|
||||||
import androidx.ui.graphics.Color
|
|
||||||
import androidx.ui.input.ImeAction
|
|
||||||
import androidx.ui.layout.Column
|
|
||||||
import androidx.ui.layout.LayoutPadding
|
|
||||||
import androidx.ui.layout.LayoutSize
|
|
||||||
import androidx.ui.layout.Row
|
|
||||||
import androidx.ui.material.Emphasis
|
|
||||||
import androidx.ui.material.MaterialTheme
|
|
||||||
import androidx.ui.material.ProvideEmphasis
|
|
||||||
import androidx.ui.text.TextStyle
|
|
||||||
import androidx.ui.tooling.preview.Preview
|
|
||||||
import androidx.ui.unit.dp
|
|
||||||
import com.geeksville.mesh.model.MessagesState
|
|
||||||
import com.geeksville.mesh.model.MessagesState.messages
|
|
||||||
import com.geeksville.mesh.model.NodeDB
|
|
||||||
import com.geeksville.mesh.model.TextMessage
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
|
|
||||||
|
|
||||||
private val dateFormat = SimpleDateFormat("h:mm a")
|
|
||||||
|
|
||||||
val TimestampEmphasis = object : Emphasis {
|
|
||||||
override fun emphasize(color: Color) = color.copy(alpha = 0.25f)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A pretty version the text, with user icon to the left, name and time of arrival (copy slack look and feel)
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun MessageCard(msg: TextMessage, modifier: Modifier = Modifier.None) {
|
|
||||||
Row(modifier = modifier) {
|
|
||||||
UserIcon(NodeDB.nodes[msg.from])
|
|
||||||
|
|
||||||
Column(modifier = LayoutPadding(start = 12.dp)) {
|
|
||||||
Row {
|
|
||||||
val nodes = NodeDB.nodes
|
|
||||||
|
|
||||||
// If we can't find the sender, just use the ID
|
|
||||||
val node = nodes.get(msg.from)
|
|
||||||
val user = node?.user
|
|
||||||
val senderName = user?.longName ?: msg.from
|
|
||||||
Text(text = senderName)
|
|
||||||
ProvideEmphasis(emphasis = TimestampEmphasis) {
|
|
||||||
Text(
|
|
||||||
text = dateFormat.format(msg.date),
|
|
||||||
modifier = LayoutPadding(start = 8.dp),
|
|
||||||
style = MaterialTheme.typography.caption
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (msg.errorMessage != null)
|
|
||||||
Text(text = msg.errorMessage, style = TextStyle(color = palette.error))
|
|
||||||
else
|
|
||||||
Text(text = msg.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MessagesContent() {
|
|
||||||
analyticsScreen(name = "messages")
|
|
||||||
|
|
||||||
Column(modifier = LayoutSize.Fill) {
|
|
||||||
|
|
||||||
val sidePad = 8.dp
|
|
||||||
val topPad = 4.dp
|
|
||||||
|
|
||||||
VerticalScroller(
|
|
||||||
modifier = LayoutWeight(1f)
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
messages.forEach { msg ->
|
|
||||||
MessageCard(
|
|
||||||
msg, modifier = LayoutPadding(
|
|
||||||
start = sidePad,
|
|
||||||
end = sidePad,
|
|
||||||
top = topPad,
|
|
||||||
bottom = topPad
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spacer(LayoutFlexible(1f))
|
|
||||||
|
|
||||||
val message = state { "" }
|
|
||||||
StyledTextField(
|
|
||||||
value = message.value,
|
|
||||||
onValueChange = { message.value = it },
|
|
||||||
textStyle = TextStyle(
|
|
||||||
color = palette.onSecondary.copy(alpha = 0.8f)
|
|
||||||
),
|
|
||||||
imeAction = ImeAction.Send,
|
|
||||||
onImeActionPerformed = {
|
|
||||||
MessagesState.info("did IME action")
|
|
||||||
|
|
||||||
val str = message.value
|
|
||||||
MessagesState.sendMessage(str)
|
|
||||||
message.value = "" // blow away the string the user just entered
|
|
||||||
},
|
|
||||||
hintText = "Type your message here..."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun previewMessagesView() {
|
|
||||||
// another bug? It seems modaldrawerlayout not yet supported in preview
|
|
||||||
MaterialTheme(colors = palette) {
|
|
||||||
MessagesContent()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,280 @@
|
||||||
|
package com.geeksville.mesh.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.EditText
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.geeksville.android.Logging
|
||||||
|
import com.geeksville.mesh.R
|
||||||
|
import com.geeksville.mesh.model.TextMessage
|
||||||
|
import com.geeksville.mesh.model.UIViewModel
|
||||||
|
import kotlinx.android.synthetic.main.adapter_message_layout.view.*
|
||||||
|
import kotlinx.android.synthetic.main.messages_fragment.*
|
||||||
|
|
||||||
|
// Allows usage like email.on(EditorInfo.IME_ACTION_NEXT, { confirm() })
|
||||||
|
fun EditText.on(actionId: Int, func: () -> Unit) {
|
||||||
|
setOnEditorActionListener { _, receivedActionId, _ ->
|
||||||
|
|
||||||
|
if (actionId == receivedActionId) {
|
||||||
|
func()
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessagesFragment : ScreenFragment("Messages"), Logging {
|
||||||
|
|
||||||
|
private val model: UIViewModel by activityViewModels()
|
||||||
|
|
||||||
|
// Provide a direct reference to each of the views within a data item
|
||||||
|
// Used to cache the views within the item layout for fast access
|
||||||
|
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||||
|
val username = itemView.username
|
||||||
|
val messageText = itemView.messageText
|
||||||
|
}
|
||||||
|
|
||||||
|
private val messagesAdapter = object : RecyclerView.Adapter<ViewHolder>() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when RecyclerView needs a new [ViewHolder] of the given type to represent
|
||||||
|
* an item.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This new ViewHolder should be constructed with a new View that can represent the items
|
||||||
|
* of the given type. You can either create a new View manually or inflate it from an XML
|
||||||
|
* layout file.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* The new ViewHolder will be used to display items of the adapter using
|
||||||
|
* [.onBindViewHolder]. Since it will be re-used to display
|
||||||
|
* different items in the data set, it is a good idea to cache references to sub views of
|
||||||
|
* the View to avoid unnecessary [View.findViewById] calls.
|
||||||
|
*
|
||||||
|
* @param parent The ViewGroup into which the new View will be added after it is bound to
|
||||||
|
* an adapter position.
|
||||||
|
* @param viewType The view type of the new View.
|
||||||
|
*
|
||||||
|
* @return A new ViewHolder that holds a View of the given view type.
|
||||||
|
* @see .getItemViewType
|
||||||
|
* @see .onBindViewHolder
|
||||||
|
*/
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(requireContext())
|
||||||
|
|
||||||
|
// Inflate the custom layout
|
||||||
|
|
||||||
|
// Inflate the custom layout
|
||||||
|
val contactView: View = inflater.inflate(R.layout.adapter_message_layout, parent, false)
|
||||||
|
|
||||||
|
// Return a new holder instance
|
||||||
|
return ViewHolder(contactView)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the total number of items in the data set held by the adapter.
|
||||||
|
*
|
||||||
|
* @return The total number of items in this adapter.
|
||||||
|
*/
|
||||||
|
override fun getItemCount(): Int = messages.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by RecyclerView to display the data at the specified position. This method should
|
||||||
|
* update the contents of the [ViewHolder.itemView] to reflect the item at the given
|
||||||
|
* position.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Note that unlike [android.widget.ListView], RecyclerView will not call this method
|
||||||
|
* again if the position of the item changes in the data set unless the item itself is
|
||||||
|
* invalidated or the new position cannot be determined. For this reason, you should only
|
||||||
|
* use the `position` parameter while acquiring the related data item inside
|
||||||
|
* this method and should not keep a copy of it. If you need the position of an item later
|
||||||
|
* on (e.g. in a click listener), use [ViewHolder.getAdapterPosition] which will
|
||||||
|
* have the updated adapter position.
|
||||||
|
*
|
||||||
|
* Override [.onBindViewHolder] instead if Adapter can
|
||||||
|
* handle efficient partial bind.
|
||||||
|
*
|
||||||
|
* @param holder The ViewHolder which should be updated to represent the contents of the
|
||||||
|
* item at the given position in the data set.
|
||||||
|
* @param position The position of the item within the adapter's data set.
|
||||||
|
*/
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
val msg = messages[position]
|
||||||
|
|
||||||
|
val nodes = model.nodeDB.nodes.value!!
|
||||||
|
|
||||||
|
// If we can't find the sender, just use the ID
|
||||||
|
val node = nodes.get(msg.from)
|
||||||
|
val user = node?.user
|
||||||
|
holder.username.text = user?.shortName ?: msg.from
|
||||||
|
|
||||||
|
if (msg.errorMessage != null) {
|
||||||
|
// FIXME, set the style to show a red error message
|
||||||
|
holder.messageText.text = msg.errorMessage
|
||||||
|
} else {
|
||||||
|
holder.messageText.text = msg.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var messages = arrayOf<TextMessage>()
|
||||||
|
|
||||||
|
/// Called when our node DB changes
|
||||||
|
fun onMessagesChanged(nodesIn: Collection<TextMessage>) {
|
||||||
|
messages = nodesIn.toTypedArray()
|
||||||
|
notifyDataSetChanged() // FIXME, this is super expensive and redraws all messages
|
||||||
|
|
||||||
|
// scroll to the last line
|
||||||
|
messageListView.scrollToPosition(this.itemCount - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
return inflater.inflate(R.layout.messages_fragment, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
messageInputText.on(EditorInfo.IME_ACTION_DONE) {
|
||||||
|
debug("did IME action")
|
||||||
|
|
||||||
|
val str = messageInputText.text.toString()
|
||||||
|
model.messagesState.sendMessage(str)
|
||||||
|
messageInputText.setText("") // blow away the string the user just entered
|
||||||
|
|
||||||
|
// requireActivity().hideKeyboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
messageListView.adapter = messagesAdapter
|
||||||
|
val layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
layoutManager.stackFromEnd = true // We want the last rows to always be shown
|
||||||
|
messageListView.layoutManager = layoutManager
|
||||||
|
|
||||||
|
model.messagesState.messages.observe(viewLifecycleOwner, Observer { it ->
|
||||||
|
messagesAdapter.onMessagesChanged(it)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
import androidx.compose.Composable
|
||||||
|
import androidx.compose.state
|
||||||
|
import androidx.ui.core.Modifier
|
||||||
|
import androidx.ui.foundation.Text
|
||||||
|
import androidx.ui.foundation.VerticalScroller
|
||||||
|
import androidx.ui.graphics.Color
|
||||||
|
import androidx.ui.input.ImeAction
|
||||||
|
import androidx.ui.layout.Column
|
||||||
|
import androidx.ui.layout.LayoutPadding
|
||||||
|
import androidx.ui.layout.LayoutSize
|
||||||
|
import androidx.ui.layout.Row
|
||||||
|
import androidx.ui.material.Emphasis
|
||||||
|
import androidx.ui.material.MaterialTheme
|
||||||
|
import androidx.ui.material.ProvideEmphasis
|
||||||
|
import androidx.ui.text.TextStyle
|
||||||
|
import androidx.ui.tooling.preview.Preview
|
||||||
|
import androidx.ui.unit.dp
|
||||||
|
import com.geeksville.mesh.model.MessagesState
|
||||||
|
import com.geeksville.mesh.model.MessagesState.messages
|
||||||
|
import com.geeksville.mesh.model.NodeDB
|
||||||
|
import com.geeksville.mesh.model.TextMessage
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
|
||||||
|
|
||||||
|
private val dateFormat = SimpleDateFormat("h:mm a")
|
||||||
|
|
||||||
|
val TimestampEmphasis = object : Emphasis {
|
||||||
|
override fun emphasize(color: Color) = color.copy(alpha = 0.25f)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// A pretty version the text, with user icon to the left, name and time of arrival (copy slack look and feel)
|
||||||
|
@Composable
|
||||||
|
fun MessageCard(msg: TextMessage, modifier: Modifier = Modifier.None) {
|
||||||
|
Row(modifier = modifier) {
|
||||||
|
UserIcon(NodeDB.nodes[msg.from])
|
||||||
|
|
||||||
|
Column(modifier = LayoutPadding(start = 12.dp)) {
|
||||||
|
Row {
|
||||||
|
val nodes = NodeDB.nodes
|
||||||
|
|
||||||
|
// If we can't find the sender, just use the ID
|
||||||
|
val node = nodes.get(msg.from)
|
||||||
|
val user = node?.user
|
||||||
|
val senderName = user?.longName ?: msg.from
|
||||||
|
Text(text = senderName)
|
||||||
|
ProvideEmphasis(emphasis = TimestampEmphasis) {
|
||||||
|
Text(
|
||||||
|
text = dateFormat.format(msg.date),
|
||||||
|
modifier = LayoutPadding(start = 8.dp),
|
||||||
|
style = MaterialTheme.typography.caption
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg.errorMessage != null)
|
||||||
|
Text(text = msg.errorMessage, style = TextStyle(color = palette.error))
|
||||||
|
else
|
||||||
|
Text(text = msg.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessagesContent() {
|
||||||
|
Column(modifier = LayoutSize.Fill) {
|
||||||
|
|
||||||
|
val sidePad = 8.dp
|
||||||
|
val topPad = 4.dp
|
||||||
|
|
||||||
|
VerticalScroller(
|
||||||
|
modifier = LayoutWeight(1f)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
messages.forEach { msg ->
|
||||||
|
MessageCard(
|
||||||
|
msg, modifier = LayoutPadding(
|
||||||
|
start = sidePad,
|
||||||
|
end = sidePad,
|
||||||
|
top = topPad,
|
||||||
|
bottom = topPad
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spacer(LayoutFlexible(1f))
|
||||||
|
|
||||||
|
val message = state { "" }
|
||||||
|
StyledTextField(
|
||||||
|
value = message.value,
|
||||||
|
onValueChange = { message.value = it },
|
||||||
|
textStyle = TextStyle(
|
||||||
|
color = palette.onSecondary.copy(alpha = 0.8f)
|
||||||
|
),
|
||||||
|
imeAction = ImeAction.Send,
|
||||||
|
onImeActionPerformed = {
|
||||||
|
MessagesState.info("did IME action")
|
||||||
|
|
||||||
|
val str = message.value
|
||||||
|
MessagesState.sendMessage(str)
|
||||||
|
message.value = "" // blow away the string the user just entered
|
||||||
|
},
|
||||||
|
hintText = "Type your message here..."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
*/
|
|
@ -1,91 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.ui.foundation.Text
|
|
||||||
import androidx.ui.layout.*
|
|
||||||
import androidx.ui.material.MaterialTheme
|
|
||||||
import androidx.ui.material.ProvideEmphasis
|
|
||||||
import androidx.ui.tooling.preview.Preview
|
|
||||||
import androidx.ui.unit.dp
|
|
||||||
import com.geeksville.mesh.NodeInfo
|
|
||||||
import com.geeksville.mesh.R
|
|
||||||
import com.geeksville.mesh.model.NodeDB
|
|
||||||
import androidx.ui.core.Modifier as Modifier1
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
@Composable
|
|
||||||
fun NodeIcon(modifier: Modifier1 = Modifier1.None, node: NodeInfo) {
|
|
||||||
Column {
|
|
||||||
Container(modifier = modifier + LayoutSize(40.dp, 40.dp)) {
|
|
||||||
VectorImage(id = if (node.user?.shortName != null) R.drawable.person else R.drawable.help)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show our shortname if possible
|
|
||||||
/* node.user?.shortName?.let {
|
|
||||||
Text(it)
|
|
||||||
} */
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CompassHeading(modifier: Modifier1 = Modifier1.None, node: NodeInfo) {
|
|
||||||
Column {
|
|
||||||
if (node.position != null) {
|
|
||||||
Container(modifier = modifier + LayoutSize(40.dp, 40.dp)) {
|
|
||||||
VectorImage(id = R.drawable.navigation)
|
|
||||||
}
|
|
||||||
} else Container(modifier = modifier + LayoutSize(40.dp, 40.dp)) {
|
|
||||||
VectorImage(id = R.drawable.help)
|
|
||||||
}
|
|
||||||
Text("2.3 km") // always reserve space for the distance even if we aren't showing it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun NodeHeading(node: NodeInfo) {
|
|
||||||
ProvideEmphasis(emphasis = MaterialTheme.emphasisLevels.high) {
|
|
||||||
Text(
|
|
||||||
node.user?.longName ?: "unknown",
|
|
||||||
style = MaterialTheme.typography.subtitle1
|
|
||||||
//modifier = LayoutWidth.Fill
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An info card for a node:
|
|
||||||
*
|
|
||||||
* on left, the icon for the user (or shortname if that is all we have) (this includes user's distance and heading arrow)
|
|
||||||
*
|
|
||||||
* Middle is users fullname
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun NodeInfoCard(node: NodeInfo) {
|
|
||||||
// Text("Node: ${it.user?.longName}")
|
|
||||||
Row(modifier = LayoutPadding(16.dp)) {
|
|
||||||
UILog.debug("showing NodeInfo $node")
|
|
||||||
|
|
||||||
UserIcon(
|
|
||||||
modifier = LayoutPadding(start = 0.dp, top = 0.dp, end = 0.dp, bottom = 0.dp),
|
|
||||||
user = node
|
|
||||||
)
|
|
||||||
|
|
||||||
NodeHeading(node)
|
|
||||||
|
|
||||||
// FIXME - show compass instead
|
|
||||||
// CompassHeading(node = node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun nodeInfoPreview() {
|
|
||||||
Column {
|
|
||||||
NodeInfoCard(NodeDB.testNodes[0])
|
|
||||||
NodeInfoCard(NodeDB.testNodeNoPosition)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.geeksville.mesh.ui
|
||||||
|
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.geeksville.android.GeeksvilleApplication
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fragment that represents a current 'screen' in our app.
|
||||||
|
*
|
||||||
|
* Useful for tracking analytics
|
||||||
|
*/
|
||||||
|
open class ScreenFragment(private val screenName: String) : Fragment() {
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
GeeksvilleApplication.analytics.sendScreenView(screenName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
GeeksvilleApplication.analytics.endScreenView()
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,79 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.compose.state
|
|
||||||
import androidx.ui.core.ContextAmbient
|
|
||||||
import androidx.ui.foundation.Text
|
|
||||||
import androidx.ui.input.ImeAction
|
|
||||||
import androidx.ui.layout.*
|
|
||||||
import androidx.ui.material.MaterialTheme
|
|
||||||
import androidx.ui.text.TextStyle
|
|
||||||
import androidx.ui.tooling.preview.Preview
|
|
||||||
import androidx.ui.unit.dp
|
|
||||||
import com.geeksville.android.Logging
|
|
||||||
import com.geeksville.mesh.model.MessagesState
|
|
||||||
import com.geeksville.mesh.model.UIState
|
|
||||||
import com.geeksville.mesh.service.RadioInterfaceService
|
|
||||||
|
|
||||||
|
|
||||||
object SettingsLog : Logging
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SettingsContent() {
|
|
||||||
//val typography = MaterialTheme.typography()
|
|
||||||
|
|
||||||
val context = ContextAmbient.current
|
|
||||||
Column(modifier = LayoutSize.Fill + LayoutPadding(16.dp)) {
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Text("Your name ", modifier = LayoutGravity.Center)
|
|
||||||
|
|
||||||
val name = state { UIState.ownerName }
|
|
||||||
StyledTextField(
|
|
||||||
value = name.value,
|
|
||||||
onValueChange = { name.value = it },
|
|
||||||
textStyle = TextStyle(
|
|
||||||
color = palette.onSecondary.copy(alpha = 0.8f)
|
|
||||||
),
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
onImeActionPerformed = {
|
|
||||||
MessagesState.info("did IME action")
|
|
||||||
val n = name.value.trim()
|
|
||||||
if (n.isNotEmpty())
|
|
||||||
UIState.setOwner(context, n)
|
|
||||||
},
|
|
||||||
hintText = "Type your name here...",
|
|
||||||
modifier = LayoutGravity.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
BTScanScreen()
|
|
||||||
|
|
||||||
val bonded = RadioInterfaceService.getBondedDeviceAddress(context) != null
|
|
||||||
if (!bonded) {
|
|
||||||
|
|
||||||
val typography = MaterialTheme.typography
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
"""
|
|
||||||
You haven't yet paired a Meshtastic compatible radio with this phone.
|
|
||||||
|
|
||||||
This application is an early alpha release, if you find problems please post on our website chat.
|
|
||||||
|
|
||||||
For more information see our web page - www.meshtastic.org.
|
|
||||||
""".trimIndent(), style = typography.body2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun previewSettings() {
|
|
||||||
// another bug? It seems modaldrawerlayout not yet supported in preview
|
|
||||||
MaterialTheme(colors = palette) {
|
|
||||||
SettingsContent()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,296 @@
|
||||||
|
package com.geeksville.mesh.ui
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.le.*
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.ParcelUuid
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.RadioButton
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import com.geeksville.android.Logging
|
||||||
|
import com.geeksville.android.hideKeyboard
|
||||||
|
import com.geeksville.mesh.R
|
||||||
|
import com.geeksville.mesh.model.UIViewModel
|
||||||
|
import com.geeksville.mesh.service.RadioInterfaceService
|
||||||
|
import com.geeksville.util.exceptionReporter
|
||||||
|
import kotlinx.android.synthetic.main.settings_fragment.*
|
||||||
|
|
||||||
|
|
||||||
|
class BTScanModel(app: Application) : AndroidViewModel(app), Logging {
|
||||||
|
|
||||||
|
private val context = getApplication<Application>().applicationContext
|
||||||
|
|
||||||
|
init {
|
||||||
|
debug("BTScanModel created")
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BTScanEntry(val name: String, val macAddress: String, val bonded: Boolean) {
|
||||||
|
// val isSelected get() = macAddress == selectedMacAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
debug("BTScanModel cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Note: may be null on platforms without a bluetooth driver (ie. the emulator)
|
||||||
|
val bluetoothAdapter =
|
||||||
|
(context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?)?.adapter
|
||||||
|
|
||||||
|
var selectedMacAddr: String? = null
|
||||||
|
val errorText = object : MutableLiveData<String?>(null) {}
|
||||||
|
|
||||||
|
|
||||||
|
private var scanner: BluetoothLeScanner? = null
|
||||||
|
|
||||||
|
private val scanCallback = object : ScanCallback() {
|
||||||
|
override fun onScanFailed(errorCode: Int) {
|
||||||
|
val msg = "Unexpected bluetooth scan failure: $errorCode"
|
||||||
|
// error code2 seeems to be indicate hung bluetooth stack
|
||||||
|
errorText.value = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each device that appears in our scan, ask for its GATT, when the gatt arrives,
|
||||||
|
// check if it is an eligable device and store it in our list of candidates
|
||||||
|
// if that device later disconnects remove it as a candidate
|
||||||
|
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||||
|
|
||||||
|
val addr = result.device.address
|
||||||
|
// prevent logspam because weill get get lots of redundant scan results
|
||||||
|
val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED
|
||||||
|
val oldDevs = devices.value!!
|
||||||
|
val oldEntry = oldDevs[addr]
|
||||||
|
if (oldEntry == null || oldEntry.bonded != isBonded) {
|
||||||
|
val entry = BTScanEntry(
|
||||||
|
result.device.name,
|
||||||
|
addr,
|
||||||
|
isBonded
|
||||||
|
)
|
||||||
|
debug("onScanResult ${entry}")
|
||||||
|
|
||||||
|
// If nothing was selected, by default select the first thing we see
|
||||||
|
if (selectedMacAddr == null && entry.bonded)
|
||||||
|
changeSelection(context, addr)
|
||||||
|
|
||||||
|
devices.value = oldDevs + Pair(addr, entry) // trigger gui updates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopScan() {
|
||||||
|
if (scanner != null) {
|
||||||
|
debug("stopping scan")
|
||||||
|
try {
|
||||||
|
scanner?.stopScan(scanCallback)
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}")
|
||||||
|
}
|
||||||
|
scanner = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startScan() {
|
||||||
|
debug("BTScan component active")
|
||||||
|
selectedMacAddr = RadioInterfaceService.getBondedDeviceAddress(context)
|
||||||
|
|
||||||
|
if (bluetoothAdapter == null) {
|
||||||
|
warn("No bluetooth adapter. Running under emulation?")
|
||||||
|
|
||||||
|
val testnodes = listOf(
|
||||||
|
BTScanEntry("Meshtastic_ab12", "xx", false),
|
||||||
|
BTScanEntry("Meshtastic_32ac", "xb", true)
|
||||||
|
)
|
||||||
|
|
||||||
|
devices.value = (testnodes.map { it.macAddress to it }).toMap()
|
||||||
|
|
||||||
|
// If nothing was selected, by default select the first thing we see
|
||||||
|
if (selectedMacAddr == null)
|
||||||
|
changeSelection(context, testnodes.first().macAddress)
|
||||||
|
} else {
|
||||||
|
/// The following call might return null if the user doesn't have bluetooth access permissions
|
||||||
|
val s: BluetoothLeScanner? = bluetoothAdapter.bluetoothLeScanner
|
||||||
|
|
||||||
|
if (s == null) {
|
||||||
|
errorText.value =
|
||||||
|
"This application requires bluetooth access. Please grant access in android settings."
|
||||||
|
} else {
|
||||||
|
debug("starting scan")
|
||||||
|
|
||||||
|
// filter and only accept devices that have a sw update service
|
||||||
|
val filter =
|
||||||
|
ScanFilter.Builder()
|
||||||
|
.setServiceUuid(ParcelUuid(RadioInterfaceService.BTM_SERVICE_UUID))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val settings =
|
||||||
|
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||||
|
.build()
|
||||||
|
s.startScan(listOf(filter), settings, scanCallback)
|
||||||
|
scanner = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val devices = object : MutableLiveData<Map<String, BTScanEntry>>(mapOf()) {
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the number of active observers change from 1 to 0.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This does not mean that there are no observers left, there may still be observers but their
|
||||||
|
* lifecycle states aren't [Lifecycle.State.STARTED] or [Lifecycle.State.RESUMED]
|
||||||
|
* (like an Activity in the back stack).
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* You can check if there are observers via [.hasObservers].
|
||||||
|
*/
|
||||||
|
override fun onInactive() {
|
||||||
|
super.onInactive()
|
||||||
|
stopScan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by the GUI when a new device has been selected by the user
|
||||||
|
/// Returns true if we were able to change to that item
|
||||||
|
fun onSelected(it: BTScanEntry): Boolean {
|
||||||
|
// If the device is paired, let user select it, otherwise start the pairing flow
|
||||||
|
if (it.bonded) {
|
||||||
|
changeSelection(context, it.macAddress)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
info("Starting bonding for $it")
|
||||||
|
|
||||||
|
// We need this receiver to get informed when the bond attempt finished
|
||||||
|
val bondChangedReceiver = object : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(
|
||||||
|
context: Context,
|
||||||
|
intent: Intent
|
||||||
|
) = exceptionReporter {
|
||||||
|
val state =
|
||||||
|
intent.getIntExtra(
|
||||||
|
BluetoothDevice.EXTRA_BOND_STATE,
|
||||||
|
-1
|
||||||
|
)
|
||||||
|
debug("Received bond state changed $state")
|
||||||
|
context.unregisterReceiver(this)
|
||||||
|
if (state == BluetoothDevice.BOND_BONDED || state == BluetoothDevice.BOND_BONDING) {
|
||||||
|
debug("Bonding completed, connecting service")
|
||||||
|
changeSelection(
|
||||||
|
context,
|
||||||
|
it.macAddress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val filter = IntentFilter()
|
||||||
|
filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||||
|
context.registerReceiver(bondChangedReceiver, filter)
|
||||||
|
|
||||||
|
// We ignore missing BT adapters, because it lets us run on the emulator
|
||||||
|
bluetoothAdapter
|
||||||
|
?.getRemoteDevice(it.macAddress)
|
||||||
|
?.createBond()
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change to a new macaddr selection, updating GUI and radio
|
||||||
|
fun changeSelection(context: Context, newAddr: String) {
|
||||||
|
info("Changing BT device to $newAddr")
|
||||||
|
selectedMacAddr = newAddr
|
||||||
|
RadioInterfaceService.setBondedDeviceAddress(context, newAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsFragment : ScreenFragment("Settings"), Logging {
|
||||||
|
|
||||||
|
private val scanModel: BTScanModel by activityViewModels()
|
||||||
|
private val model: UIViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
return inflater.inflate(R.layout.settings_fragment, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
model.ownerName.observe(viewLifecycleOwner, Observer { name ->
|
||||||
|
usernameEditText.setText(name)
|
||||||
|
})
|
||||||
|
|
||||||
|
usernameEditText.on(EditorInfo.IME_ACTION_DONE) {
|
||||||
|
debug("did IME action")
|
||||||
|
val n = usernameEditText.text.toString().trim()
|
||||||
|
if (n.isNotEmpty())
|
||||||
|
model.setOwner(requireContext(), n)
|
||||||
|
|
||||||
|
requireActivity().hideKeyboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
analyticsOkayCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
// FIXME, preserve this in settings
|
||||||
|
analyticsOkayCheckbox.isChecked = true // so users will complain and I'll fix the bug
|
||||||
|
}
|
||||||
|
|
||||||
|
scanModel.errorText.observe(viewLifecycleOwner, Observer { errMsg ->
|
||||||
|
if (errMsg != null) {
|
||||||
|
scanStatusText.text = errMsg
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
scanModel.devices.observe(viewLifecycleOwner, Observer { devices ->
|
||||||
|
// Remove the old radio buttons and repopulate
|
||||||
|
deviceRadioGroup.removeAllViews()
|
||||||
|
|
||||||
|
devices.values.forEach { device ->
|
||||||
|
val b = RadioButton(requireActivity())
|
||||||
|
b.text = device.name
|
||||||
|
b.id = View.generateViewId()
|
||||||
|
b.isEnabled =
|
||||||
|
true // Now we always want to enable, if the user clicks we'll try to bond device.bonded
|
||||||
|
b.isSelected = device.macAddress == scanModel.selectedMacAddr
|
||||||
|
deviceRadioGroup.addView(b)
|
||||||
|
|
||||||
|
b.setOnClickListener {
|
||||||
|
b.isChecked = scanModel.onSelected(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasBonded = RadioInterfaceService.getBondedDeviceAddress(requireContext()) != null
|
||||||
|
|
||||||
|
// get rid of the warning text once at least one device is paired
|
||||||
|
warningNotPaired.visibility = if (hasBonded) View.GONE else View.VISIBLE
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
scanModel.stopScan()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
scanModel.startScan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import androidx.compose.Model
|
|
||||||
import com.geeksville.mesh.R
|
|
||||||
|
|
||||||
|
|
||||||
data class ScreenInfo(val icon: Int, val label: String)
|
|
||||||
|
|
||||||
// defines the screens we have in the app
|
|
||||||
object Screen {
|
|
||||||
val settings = ScreenInfo(R.drawable.ic_twotone_settings_applications_24, "Settings")
|
|
||||||
val channel = ScreenInfo(R.drawable.ic_twotone_contactless_24, "Channel")
|
|
||||||
val users = ScreenInfo(R.drawable.ic_twotone_people_24, "Users")
|
|
||||||
val messages = ScreenInfo(R.drawable.ic_twotone_message_24, "Messages")
|
|
||||||
val map = ScreenInfo(R.drawable.ic_twotone_map_24, "Map")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Model
|
|
||||||
object AppStatus {
|
|
||||||
var currentScreen: ScreenInfo = Screen.messages
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Temporary solution pending navigation support.
|
|
||||||
*/
|
|
||||||
fun navigateTo(destination: ScreenInfo) {
|
|
||||||
AppStatus.currentScreen = destination
|
|
||||||
}
|
|
|
@ -1,75 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.compose.state
|
|
||||||
import androidx.ui.core.Modifier
|
|
||||||
import androidx.ui.foundation.TextField
|
|
||||||
import androidx.ui.foundation.shape.corner.RoundedCornerShape
|
|
||||||
import androidx.ui.graphics.Color
|
|
||||||
import androidx.ui.input.ImeAction
|
|
||||||
import androidx.ui.input.KeyboardType
|
|
||||||
import androidx.ui.input.VisualTransformation
|
|
||||||
import androidx.ui.layout.LayoutPadding
|
|
||||||
import androidx.ui.material.Emphasis
|
|
||||||
import androidx.ui.material.MaterialTheme
|
|
||||||
import androidx.ui.material.ProvideEmphasis
|
|
||||||
import androidx.ui.material.Surface
|
|
||||||
import androidx.ui.text.TextStyle
|
|
||||||
import androidx.ui.unit.dp
|
|
||||||
|
|
||||||
|
|
||||||
val HintEmphasis = object : Emphasis {
|
|
||||||
override fun emphasize(color: Color) = color.copy(alpha = 0.05f)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// A text field that visually conveys that it is editable - FIXME, once Compose has material
|
|
||||||
/// design text fields use that instead.
|
|
||||||
@Composable
|
|
||||||
fun StyledTextField(
|
|
||||||
value: String,
|
|
||||||
modifier: Modifier = Modifier.None,
|
|
||||||
onValueChange: (String) -> Unit = {},
|
|
||||||
textStyle: TextStyle = TextStyle.Default,
|
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
|
||||||
imeAction: ImeAction = ImeAction.Unspecified,
|
|
||||||
onFocus: () -> Unit = {},
|
|
||||||
onBlur: () -> Unit = {},
|
|
||||||
focusIdentifier: String? = null,
|
|
||||||
onImeActionPerformed: (ImeAction) -> Unit = {},
|
|
||||||
visualTransformation: VisualTransformation? = null,
|
|
||||||
hintText: String = ""
|
|
||||||
) {
|
|
||||||
val backgroundColor = palette.secondary.copy(alpha = 0.12f)
|
|
||||||
Surface(
|
|
||||||
modifier = LayoutPadding(8.dp),
|
|
||||||
color = backgroundColor,
|
|
||||||
shape = RoundedCornerShape(4.dp)
|
|
||||||
) {
|
|
||||||
val showingHint = state { value.isEmpty() }
|
|
||||||
val level = if (showingHint.value) HintEmphasis else MaterialTheme.emphasisLevels.medium
|
|
||||||
|
|
||||||
ProvideEmphasis(level) {
|
|
||||||
TextField(
|
|
||||||
value.ifEmpty { if (showingHint.value) hintText else "" },
|
|
||||||
modifier + LayoutPadding(4.dp),
|
|
||||||
onValueChange,
|
|
||||||
textStyle,
|
|
||||||
keyboardType,
|
|
||||||
imeAction,
|
|
||||||
{
|
|
||||||
showingHint.value = false // Stop showing the hint now
|
|
||||||
onFocus()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// if the string is empty again, return to the hint text
|
|
||||||
showingHint.value = value.isEmpty()
|
|
||||||
onBlur()
|
|
||||||
},
|
|
||||||
focusIdentifier,
|
|
||||||
onImeActionPerformed,
|
|
||||||
visualTransformation
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,44 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.ui.core.Modifier
|
|
||||||
import androidx.ui.foundation.Text
|
|
||||||
import androidx.ui.layout.Column
|
|
||||||
import androidx.ui.layout.LayoutGravity
|
|
||||||
import androidx.ui.layout.LayoutWidth
|
|
||||||
import androidx.ui.material.MaterialTheme
|
|
||||||
import androidx.ui.tooling.preview.Preview
|
|
||||||
import androidx.ui.unit.dp
|
|
||||||
import com.geeksville.mesh.NodeInfo
|
|
||||||
import com.geeksville.mesh.R
|
|
||||||
import com.geeksville.mesh.model.NodeDB
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the user icon for a particular user with distance from the operator and a small pointer
|
|
||||||
* indicating their direction
|
|
||||||
*
|
|
||||||
* This component is fixed width to simplify layouts.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun UserIcon(user: NodeInfo? = null, modifier: Modifier = Modifier.None) {
|
|
||||||
Column(modifier = modifier + LayoutWidth(60.dp)) {
|
|
||||||
VectorImage(
|
|
||||||
id = R.drawable.ic_twotone_person_24,
|
|
||||||
tint = palette.onSecondary,
|
|
||||||
modifier = LayoutGravity.Center
|
|
||||||
)
|
|
||||||
val ourNodeInfo = NodeDB.ourNodeInfo
|
|
||||||
val distance = ourNodeInfo?.distanceStr(user)
|
|
||||||
if (distance != null)
|
|
||||||
Text(distance, modifier = LayoutGravity.Center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun previewUserIcon() {
|
|
||||||
// another bug? It seems modaldrawerlayout not yet supported in preview
|
|
||||||
MaterialTheme(colors = palette) {
|
|
||||||
UserIcon(NodeDB.testNodes[1])
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,92 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.ui.core.ContextAmbient
|
|
||||||
import androidx.ui.foundation.Text
|
|
||||||
import androidx.ui.layout.Column
|
|
||||||
import androidx.ui.layout.LayoutPadding
|
|
||||||
import androidx.ui.layout.Row
|
|
||||||
import androidx.ui.material.Button
|
|
||||||
import androidx.ui.unit.dp
|
|
||||||
import com.geeksville.mesh.R
|
|
||||||
import com.geeksville.mesh.model.NodeDB
|
|
||||||
import com.geeksville.mesh.model.UIState
|
|
||||||
import com.geeksville.mesh.service.MeshService
|
|
||||||
import com.geeksville.mesh.service.RadioInterfaceService
|
|
||||||
import com.geeksville.mesh.service.SoftwareUpdateService
|
|
||||||
|
|
||||||
|
|
||||||
/// Given a human name, strip out the first letter of the first three words and return that as the initials for
|
|
||||||
/// that user.
|
|
||||||
fun getInitials(name: String): String {
|
|
||||||
val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(3).map { it.first() }
|
|
||||||
.joinToString("")
|
|
||||||
|
|
||||||
return words
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun UsersContent() {
|
|
||||||
analyticsScreen(name = "users")
|
|
||||||
|
|
||||||
Column {
|
|
||||||
Row {
|
|
||||||
fun connected() = UIState.isConnected.value != MeshService.ConnectionState.DISCONNECTED
|
|
||||||
VectorImage(
|
|
||||||
id = if (connected()) R.drawable.cloud_on else R.drawable.cloud_off,
|
|
||||||
tint = palette.onBackground,
|
|
||||||
modifier = LayoutPadding(start = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Column {
|
|
||||||
|
|
||||||
Text(
|
|
||||||
when (UIState.isConnected.value) {
|
|
||||||
MeshService.ConnectionState.CONNECTED -> "Connected"
|
|
||||||
MeshService.ConnectionState.DISCONNECTED -> "Disconnected"
|
|
||||||
MeshService.ConnectionState.DEVICE_SLEEP -> "Power Saving"
|
|
||||||
},
|
|
||||||
modifier = LayoutPadding(start = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (false) { // hide the firmware update button for now, it is kinda ugly and users don't need it yet
|
|
||||||
/// Create a software update button
|
|
||||||
val context = ContextAmbient.current
|
|
||||||
RadioInterfaceService.getBondedDeviceAddress(context)?.let { macAddress ->
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
SoftwareUpdateService.enqueueWork(
|
|
||||||
context,
|
|
||||||
SoftwareUpdateService.startUpdateIntent(macAddress)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(text = "Update firmware")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NodeDB.nodes.values.forEach {
|
|
||||||
NodeInfoCard(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* FIXME - doens't work yet - probably because I'm not using release keys
|
|
||||||
// If account is null, then show the signin button, otherwise
|
|
||||||
val context = ambient(ContextAmbient)
|
|
||||||
val account = GoogleSignIn.getLastSignedInAccount(context)
|
|
||||||
if (account != null)
|
|
||||||
Text("We have an account")
|
|
||||||
else {
|
|
||||||
Text("No account yet")
|
|
||||||
if (context is Activity) {
|
|
||||||
Button("Google sign-in", onClick = {
|
|
||||||
val signInIntent: Intent = UIState.googleSignInClient.signInIntent
|
|
||||||
context.startActivityForResult(signInIntent, MainActivity.RC_SIGN_IN)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} */
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,182 @@
|
||||||
|
package com.geeksville.mesh.ui
|
||||||
|
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.geeksville.android.Logging
|
||||||
|
import com.geeksville.mesh.NodeInfo
|
||||||
|
import com.geeksville.mesh.R
|
||||||
|
import com.geeksville.mesh.model.UIViewModel
|
||||||
|
import kotlinx.android.synthetic.main.adapter_node_layout.view.*
|
||||||
|
import kotlinx.android.synthetic.main.nodelist_fragment.*
|
||||||
|
|
||||||
|
|
||||||
|
class UsersFragment : ScreenFragment("Users"), Logging {
|
||||||
|
|
||||||
|
private val model: UIViewModel by activityViewModels()
|
||||||
|
|
||||||
|
// Provide a direct reference to each of the views within a data item
|
||||||
|
// Used to cache the views within the item layout for fast access
|
||||||
|
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||||
|
val nodeNameView = itemView.nodeNameView
|
||||||
|
val distance_view = itemView.distance_view
|
||||||
|
}
|
||||||
|
|
||||||
|
private val nodesAdapter = object : RecyclerView.Adapter<ViewHolder>() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when RecyclerView needs a new [ViewHolder] of the given type to represent
|
||||||
|
* an item.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This new ViewHolder should be constructed with a new View that can represent the items
|
||||||
|
* of the given type. You can either create a new View manually or inflate it from an XML
|
||||||
|
* layout file.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* The new ViewHolder will be used to display items of the adapter using
|
||||||
|
* [.onBindViewHolder]. Since it will be re-used to display
|
||||||
|
* different items in the data set, it is a good idea to cache references to sub views of
|
||||||
|
* the View to avoid unnecessary [View.findViewById] calls.
|
||||||
|
*
|
||||||
|
* @param parent The ViewGroup into which the new View will be added after it is bound to
|
||||||
|
* an adapter position.
|
||||||
|
* @param viewType The view type of the new View.
|
||||||
|
*
|
||||||
|
* @return A new ViewHolder that holds a View of the given view type.
|
||||||
|
* @see .getItemViewType
|
||||||
|
* @see .onBindViewHolder
|
||||||
|
*/
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(requireContext())
|
||||||
|
|
||||||
|
// Inflate the custom layout
|
||||||
|
|
||||||
|
// Inflate the custom layout
|
||||||
|
val contactView: View = inflater.inflate(R.layout.adapter_node_layout, parent, false)
|
||||||
|
|
||||||
|
// Return a new holder instance
|
||||||
|
return ViewHolder(contactView)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the total number of items in the data set held by the adapter.
|
||||||
|
*
|
||||||
|
* @return The total number of items in this adapter.
|
||||||
|
*/
|
||||||
|
override fun getItemCount(): Int = nodes.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by RecyclerView to display the data at the specified position. This method should
|
||||||
|
* update the contents of the [ViewHolder.itemView] to reflect the item at the given
|
||||||
|
* position.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Note that unlike [android.widget.ListView], RecyclerView will not call this method
|
||||||
|
* again if the position of the item changes in the data set unless the item itself is
|
||||||
|
* invalidated or the new position cannot be determined. For this reason, you should only
|
||||||
|
* use the `position` parameter while acquiring the related data item inside
|
||||||
|
* this method and should not keep a copy of it. If you need the position of an item later
|
||||||
|
* on (e.g. in a click listener), use [ViewHolder.getAdapterPosition] which will
|
||||||
|
* have the updated adapter position.
|
||||||
|
*
|
||||||
|
* Override [.onBindViewHolder] instead if Adapter can
|
||||||
|
* handle efficient partial bind.
|
||||||
|
*
|
||||||
|
* @param holder The ViewHolder which should be updated to represent the contents of the
|
||||||
|
* item at the given position in the data set.
|
||||||
|
* @param position The position of the item within the adapter's data set.
|
||||||
|
*/
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
val n = nodes[position]
|
||||||
|
|
||||||
|
holder.nodeNameView.text = n.user?.longName ?: n.user?.id ?: "Unknown node"
|
||||||
|
|
||||||
|
val ourNodeInfo = model.nodeDB.ourNodeInfo
|
||||||
|
val distance = ourNodeInfo?.distanceStr(n)
|
||||||
|
if (distance != null) {
|
||||||
|
holder.distance_view.text = distance
|
||||||
|
holder.distance_view.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
holder.distance_view.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nodes = arrayOf<NodeInfo>()
|
||||||
|
|
||||||
|
/// Called when our node DB changes
|
||||||
|
fun onNodesChanged(nodesIn: Collection<NodeInfo>) {
|
||||||
|
nodes = nodesIn.toTypedArray()
|
||||||
|
notifyDataSetChanged() // FIXME, this is super expensive and redraws all nodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
return inflater.inflate(R.layout.nodelist_fragment, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
nodeListView.adapter = nodesAdapter
|
||||||
|
nodeListView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
|
||||||
|
model.nodeDB.nodes.observe(viewLifecycleOwner, Observer { it ->
|
||||||
|
nodesAdapter.onNodesChanged(it.values)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
|
||||||
|
if (false) { // hide the firmware update button for now, it is kinda ugly and users don't need it yet
|
||||||
|
/// Create a software update button
|
||||||
|
val context = ContextAmbient.current
|
||||||
|
RadioInterfaceService.getBondedDeviceAddress(context)?.let { macAddress ->
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
SoftwareUpdateService.enqueueWork(
|
||||||
|
context,
|
||||||
|
SoftwareUpdateService.startUpdateIntent(macAddress)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(text = "Update firmware")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* FIXME - doens't work yet - probably because I'm not using release keys
|
||||||
|
// If account is null, then show the signin button, otherwise
|
||||||
|
val context = ambient(ContextAmbient)
|
||||||
|
val account = GoogleSignIn.getLastSignedInAccount(context)
|
||||||
|
if (account != null)
|
||||||
|
Text("We have an account")
|
||||||
|
else {
|
||||||
|
Text("No account yet")
|
||||||
|
if (context is Activity) {
|
||||||
|
Button("Google sign-in", onClick = {
|
||||||
|
val signInIntent: Intent = UIState.googleSignInClient.signInIntent
|
||||||
|
context.startActivityForResult(signInIntent, MainActivity.RC_SIGN_IN)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
|
@ -1,49 +0,0 @@
|
||||||
package com.geeksville.mesh.ui
|
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.compose.Composable
|
|
||||||
import androidx.ui.core.Modifier
|
|
||||||
import androidx.ui.foundation.Icon
|
|
||||||
import androidx.ui.graphics.Color
|
|
||||||
import androidx.ui.graphics.vector.drawVector
|
|
||||||
import androidx.ui.layout.Container
|
|
||||||
import androidx.ui.layout.LayoutSize
|
|
||||||
import androidx.ui.material.IconButton
|
|
||||||
import androidx.ui.res.vectorResource
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun VectorImageButton(@DrawableRes id: Int, onClick: () -> Unit) {
|
|
||||||
//Ripple(bounded = false) {
|
|
||||||
IconButton(onClick = onClick) {
|
|
||||||
Icon(vectorResource(id) /* , modifier = LayoutSize(40.dp, 40.dp) */)
|
|
||||||
}
|
|
||||||
//}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* fun AppBarIcon(icon: Image, onClick: () -> Unit) {
|
|
||||||
Container(width = ActionIconDiameter, height = ActionIconDiameter) {
|
|
||||||
Ripple(bounded = false) {
|
|
||||||
Clickable(onClick = onClick) {
|
|
||||||
SimpleImage(icon)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} */
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun VectorImage(
|
|
||||||
modifier: Modifier = Modifier.None, @DrawableRes id: Int,
|
|
||||||
tint: Color = Color.Transparent
|
|
||||||
) {
|
|
||||||
val vector = vectorResource(id)
|
|
||||||
// WithDensity {
|
|
||||||
Container(
|
|
||||||
modifier = modifier + LayoutSize(
|
|
||||||
vector.defaultWidth,
|
|
||||||
vector.defaultHeight
|
|
||||||
) + drawVector(vector, tint)
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
// }
|
|
||||||
}
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19.21,12.04l-1.53,-0.11 -0.3,-1.5C16.88,7.86 14.62,6 12,6 9.94,6 8.08,7.14 7.12,8.96l-0.5,0.95 -1.07,0.11C3.53,10.24 2,11.95 2,14c0,2.21 1.79,4 4,4h13c1.65,0 3,-1.35 3,-3 0,-1.55 -1.22,-2.86 -2.79,-2.96zM13.45,13v3h-2.91v-3L8,13l4,-4 4,4h-2.55z"
|
||||||
|
android:strokeAlpha="0.3"
|
||||||
|
android:fillAlpha="0.3"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM19,18H6c-2.21,0 -4,-1.79 -4,-4 0,-2.05 1.53,-3.76 3.56,-3.97l1.07,-0.11 0.5,-0.95C8.08,7.14 9.94,6 12,6c2.62,0 4.88,1.86 5.39,4.43l0.3,1.5 1.53,0.11c1.56,0.1 2.78,1.41 2.78,2.96 0,1.65 -1.35,3 -3,3zM8,13h2.55v3h2.9v-3H16l-4,-4z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item
|
||||||
|
android:drawable="@drawable/ic_twotone_lock_24"
|
||||||
|
android:state_checked="false"
|
||||||
|
/>
|
||||||
|
<item
|
||||||
|
android:drawable="@drawable/ic_twotone_lock_open_24"
|
||||||
|
android:state_checked="true"
|
||||||
|
/>
|
||||||
|
<item android:drawable="@drawable/ic_twotone_lock_24" />
|
||||||
|
</selector>
|
|
@ -0,0 +1,96 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:theme="@style/AppTheme.AppBarOverlay">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/imageView4"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:contentDescription="@string/application_icon"
|
||||||
|
android:scaleType="center"
|
||||||
|
android:scaleX="1.5"
|
||||||
|
android:scaleY="1.5"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:minHeight="?actionBarSize"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:text="@string/app_name"
|
||||||
|
android:textAppearance="@style/TextAppearance.Widget.AppCompat.Toolbar.Title"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/imageView4"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/connectStatusImage"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:contentDescription="@string/connection_status"
|
||||||
|
android:tint="#FFFFFF"
|
||||||
|
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
|
||||||
|
app:layout_constraintHeight_percent=".5"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:srcCompat="@drawable/cloud_off" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.tabs.TabLayout
|
||||||
|
android:id="@+id/tab_layout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<!-- Screen.messages -> MessagesContent()
|
||||||
|
Screen.settings -> SettingsContent()
|
||||||
|
Screen.users -> UsersContent()
|
||||||
|
Screen.channel -> ChannelContent(UIState.getChannel())
|
||||||
|
Screen.map -> MapContent()
|
||||||
|
|
||||||
|
<com.google.android.material.tabs.TabItem
|
||||||
|
android:icon="@drawable/ic_twotone_message_24"
|
||||||
|
android:text="Messages"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_width="wrap_content" />
|
||||||
|
|
||||||
|
<com.google.android.material.tabs.TabItem
|
||||||
|
android:icon="@drawable/ic_twotone_settings_applications_24"
|
||||||
|
android:text="Settings"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_width="wrap_content" />
|
||||||
|
-->
|
||||||
|
|
||||||
|
</com.google.android.material.tabs.TabLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<androidx.viewpager2.widget.ViewPager2
|
||||||
|
android:id="@+id/pager"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="4dp"
|
||||||
|
android:clipToPadding="false">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/username"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/some_username"
|
||||||
|
app:chipIcon="@drawable/ic_twotone_person_24"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/messageText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:text="@string/sample_message"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/username"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:clipToPadding="false">
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
style="@style/Widget.App.CardView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/imageView"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:contentDescription="@string/user_avatar"
|
||||||
|
android:scaleType="center"
|
||||||
|
android:scaleX="1.5"
|
||||||
|
android:scaleY="1.5"
|
||||||
|
app:layout_constraintEnd_toEndOf="@+id/distance_view"
|
||||||
|
app:layout_constraintStart_toStartOf="@+id/distance_view"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:srcCompat="@drawable/ic_twotone_person_24" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/nodeNameView"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="@string/unknown_username"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/distance_view"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/imageView" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/distance_view"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:text="@string/sample_distance"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/imageView" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
</LinearLayout>
|
|
@ -0,0 +1,113 @@
|
||||||
|
<?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"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/channelNameView"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="64dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginEnd="64dp"
|
||||||
|
android:hint="@string/channel_name"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/channelNameEdit"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/channel_name"
|
||||||
|
android:text="@string/unset" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/qrView"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="200dp"
|
||||||
|
android:layout_marginStart="96dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginEnd="96dp"
|
||||||
|
android:contentDescription="@string/qr_code"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/channelNameView"
|
||||||
|
app:srcCompat="@drawable/qrcode" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
geeksville: no longer used but keeping as a good example of a button group. instead I use
|
||||||
|
a toggleable icon.
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||||
|
android:id="@+id/editGroup"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="96dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:singleSelection="true"
|
||||||
|
app:selectionRequired="true"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/channelOptions">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/locked"
|
||||||
|
style="@style/Widget.App.Button.OutlinedButton.IconOnly"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
app:icon="@drawable/ic_twotone_lock_24" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/unlocked"
|
||||||
|
style="@style/Widget.App.Button.OutlinedButton.IconOnly"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
app:icon="@drawable/ic_twotone_lock_open_24" />
|
||||||
|
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||||
|
-->
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/editableCheckbox"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="96dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
android:button="@drawable/sl_lock_24dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/channelOptions"></CheckBox>
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/shareButton"
|
||||||
|
style="@android:style/Widget.Material.ImageButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="96dp"
|
||||||
|
android:contentDescription="@string/share_button"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/editableCheckbox"
|
||||||
|
app:srcCompat="@drawable/ic_twotone_share_24" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/channelOptions"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="64dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginEnd="64dp"
|
||||||
|
android:hint="@string/channel_options"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/qrView">
|
||||||
|
|
||||||
|
<AutoCompleteTextView
|
||||||
|
android:id="@+id/filled_exposed_dropdown"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/channel_options" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textAppearance="?attr/textAppearanceSubtitle1" />
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/constraintLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
</FrameLayout>
|
|
@ -1,10 +1,17 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
<com.mapbox.mapboxsdk.maps.MapView xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:mapbox="http://schemas.android.com/apk/res-auto"
|
xmlns:mapbox="http://schemas.android.com/apk/res-auto"
|
||||||
android:id="@+id/mapView"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:clickable="true"
|
android:id="@+id/mapFrame"> <!-- tab layout requires a unique ID -->
|
||||||
mapbox:mapbox_uiZoomGestures="true"
|
|
||||||
mapbox:mapbox_uiScrollGestures="true"></com.mapbox.mapboxsdk.maps.MapView>
|
<com.mapbox.mapboxsdk.maps.MapView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/mapView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:clickable="true"
|
||||||
|
mapbox:mapbox_uiZoomGestures="true"
|
||||||
|
mapbox:mapbox_uiScrollGestures="true"></com.mapbox.mapboxsdk.maps.MapView>
|
||||||
|
|
||||||
|
</FrameLayout>
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?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"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/messageListView"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/textInputLayout"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/textInputLayout"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:hint="@string/send_text"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/messageInputText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:imeOptions="actionDone"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:text="" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?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"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/nodeListView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,98 @@
|
||||||
|
<?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"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/warningNotPaired"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:ems="10"
|
||||||
|
android:gravity="start|top"
|
||||||
|
android:text="@string/warning_not_paired"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/deviceRadioGroup" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/textInputLayout2"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:hint="@string/your_name"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/usernameEditText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:imeOptions="actionDone" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/scanStatusText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="32dp"
|
||||||
|
android:text="@string/looking_for_meshtastic_devices"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/textInputLayout2" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/scanProgressBar"
|
||||||
|
style="?android:attr/progressBarStyle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/deviceRadioGroup" />
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
android:id="@+id/deviceRadioGroup"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/scanStatusText">
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioButton2"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/test__devname1" />
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/test_devname2" />
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/analyticsOkayCheckbox"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:checked="true"
|
||||||
|
android:text="@string/analytics_okay"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/deviceRadioGroup"
|
||||||
|
app:layout_constraintVertical_bias="1.0" />
|
||||||
|
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -1,4 +1,24 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Meshtastic</string>
|
<string name="app_name" translatable="false">Meshtastic</string>
|
||||||
<string name="action_settings">Settings</string>
|
<string name="action_settings">Settings</string>
|
||||||
|
<string name="channel_name">Channel Name</string>
|
||||||
|
<string name="channel_options">Channel options</string>
|
||||||
|
<string name="share_button">Share button</string>
|
||||||
|
<string name="qr_code">QR code</string>
|
||||||
|
<string name="unset">Unset</string>
|
||||||
|
<string name="connection_status">Connection status</string>
|
||||||
|
<string name="application_icon">application icon</string>
|
||||||
|
<string name="unknown_username">Unknown Username</string>
|
||||||
|
<string name="user_avatar">User avatar</string>
|
||||||
|
<string name="sample_distance" translatable="false">2.13 km</string>
|
||||||
|
<string name="sample_message">hey I found the cache, it is over here next to the big tiger. I\'m kinda scared.</string>
|
||||||
|
<string name="some_username">Some Username</string>
|
||||||
|
<string name="send_text">Send Text</string>
|
||||||
|
<string name="warning_not_paired">You haven\'t yet paired a Meshtastic compatible radio with this phone. Please pair a device and set your username.\n\nThis open-source application is in alpha-testing, if you find problems please post on our website chat.\n\nFor more information see our web page - www.meshtastic.org.</string>
|
||||||
|
<string name="username_unset">Username unset</string>
|
||||||
|
<string name="your_name">Your Name</string>
|
||||||
|
<string name="analytics_okay">Anonymous usage statistics and crash reports.</string>
|
||||||
|
<string name="looking_for_meshtastic_devices">Looking for Meshtastic devices...</string>
|
||||||
|
<string name="test__devname1" translatable="false">Meshtastic_ac23</string>
|
||||||
|
<string name="test_devname2" translatable="false">Meshtastic_1267</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
<style name="AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
|
||||||
<!-- Customize your theme here. -->
|
<!-- Customize your theme here. -->
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
<item name="colorAccent">@color/colorAccent</item>
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBar">
|
<style name="AppTheme.NoActionBar" parent="Theme.MaterialComponents.Light.NoActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
</style>
|
</style>
|
||||||
|
@ -17,4 +18,15 @@
|
||||||
|
|
||||||
<style name="AppTheme.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
|
<style name="AppTheme.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
|
||||||
|
|
||||||
|
<style name="Widget.App.Button.OutlinedButton.IconOnly" parent="Widget.MaterialComponents.Button.OutlinedButton">
|
||||||
|
<item name="iconPadding">0dp</item>
|
||||||
|
<item name="android:insetTop">0dp</item>
|
||||||
|
<item name="android:insetBottom">0dp</item>
|
||||||
|
<item name="android:paddingLeft">12dp</item>
|
||||||
|
<item name="android:paddingRight">12dp</item>
|
||||||
|
<item name="android:minWidth">48dp</item>
|
||||||
|
<item name="android:minHeight">48dp</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Widget.App.CardView" parent="Widget.MaterialComponents.CardView"></style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.3.61'
|
ext.kotlin_version = '1.3.71'
|
||||||
ext.compose_version = '0.1.0-dev08'
|
|
||||||
ext.coroutines_version = "1.3.5"
|
ext.coroutines_version = "1.3.5"
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
|
@ -11,7 +10,7 @@ buildscript {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:4.1.0-alpha04'
|
classpath 'com.android.tools.build:gradle:4.0.0-beta04'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
|
@ -23,7 +22,7 @@ buildscript {
|
||||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.0.0-beta03'
|
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.0.0-beta03'
|
||||||
|
|
||||||
// protobuf plugin - docs here https://github.com/google/protobuf-gradle-plugin
|
// protobuf plugin - docs here https://github.com/google/protobuf-gradle-plugin
|
||||||
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.11'
|
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.12'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit 65f39f90ce365263620d5f9cbddca0c8abebcf9a
|
Subproject commit ebc40c05fd8c30aabb3070468627e5fe6ae59cd5
|
Ładowanie…
Reference in New Issue