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>
|
||||
</option>
|
||||
<option name="resolveModulePerSourceSet" value="false" />
|
||||
<option name="useQualifiedModuleNames" value="true" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</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 {
|
||||
// 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.
|
||||
|
@ -44,8 +44,8 @@ android {
|
|||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerVersion "1.3.61-dev-withExperimentalGoogleExtensions-20200129"
|
||||
kotlinCompilerExtensionVersion "$compose_version"
|
||||
//kotlinCompilerVersion "1.3.61-dev-withExperimentalGoogleExtensions-20200129"
|
||||
//kotlinCompilerExtensionVersion "$compose_version"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -73,11 +73,17 @@ protobuf {
|
|||
|
||||
dependencies {
|
||||
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.core:core-ktx:1.2.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
implementation 'com.google.android.material:material:1.0.0'
|
||||
implementation "androidx.fragment:fragment-ktx:1.2.4"
|
||||
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'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
|
||||
|
@ -95,17 +101,6 @@ dependencies {
|
|||
// mapbox
|
||||
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
|
||||
implementation 'com.google.android.gms:play-services-location:17.0.0'
|
||||
|
||||
|
|
|
@ -34,7 +34,9 @@
|
|||
<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 -->
|
||||
<uses-permission android:name="android.permission.CAMERA" tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.CAMERA"
|
||||
tools:node="remove" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth_le"
|
||||
|
@ -84,6 +86,7 @@
|
|||
android:name="com.geeksville.mesh.MainActivity"
|
||||
android:label="@string/app_name"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
android:theme="@style/AppTheme.NoActionBar">
|
||||
<intent-filter>
|
||||
<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
|
||||
|
||||
// import kotlinx.android.synthetic.main.tabs.*
|
||||
import android.Manifest
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothManager
|
||||
|
@ -15,29 +16,28 @@ import android.view.Menu
|
|||
import android.view.MenuItem
|
||||
import android.view.MotionEvent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
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.ServiceClient
|
||||
import com.geeksville.mesh.model.MessagesState
|
||||
import com.geeksville.mesh.model.NodeDB
|
||||
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.ui.AppStatus
|
||||
import com.geeksville.mesh.ui.MeshApp
|
||||
import com.geeksville.mesh.ui.ScanState
|
||||
import com.geeksville.mesh.ui.Screen
|
||||
import com.geeksville.mesh.ui.*
|
||||
import com.geeksville.util.Exceptions
|
||||
import com.geeksville.util.exceptionReporter
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
|
||||
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
|
||||
|
||||
|
||||
/*
|
||||
UI design
|
||||
|
||||
|
@ -105,6 +105,55 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
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() {
|
||||
debug("Checking permissions")
|
||||
|
||||
|
@ -139,7 +188,11 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
}
|
||||
|
||||
// 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
|
||||
// app-defined int constant. The callback method gets the
|
||||
|
@ -161,7 +214,7 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
|
||||
private fun sendTestPackets() {
|
||||
exceptionReporter {
|
||||
val m = UIState.meshService!!
|
||||
val m = model.meshService!!
|
||||
|
||||
// Do some test operations
|
||||
val testPayload = "hello world".toByteArray()
|
||||
|
@ -182,10 +235,8 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val prefs = UIState.getPreferences(this)
|
||||
UIState.ownerName = prefs.getString("owner", "")!!
|
||||
UIState.meshService = null
|
||||
UIState.savedInstanceState = savedInstanceState
|
||||
val prefs = UIViewModel.getPreferences(this)
|
||||
model.ownerName.value = prefs.getString("owner", "")!!
|
||||
|
||||
// Ensures Bluetooth is available on the device and it is enabled. If not,
|
||||
// displays a dialog requesting user permission to enable Bluetooth.
|
||||
|
@ -195,7 +246,11 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(this, "Error - this app requires bluetooth", Toast.LENGTH_LONG)
|
||||
Toast.makeText(
|
||||
this,
|
||||
"Error - this app requires bluetooth",
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
.show()
|
||||
}
|
||||
|
||||
|
@ -220,11 +275,32 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
// Handle any intent
|
||||
handleIntent(intent)
|
||||
|
||||
setContent {
|
||||
/* setContent {
|
||||
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) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
|
@ -235,26 +311,26 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
val appLinkAction = intent.action
|
||||
val appLinkData: Uri? = intent.data
|
||||
|
||||
UIState.requestedChannelUrl = null // assume none
|
||||
|
||||
// Were we asked to open one our channel URLs?
|
||||
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")
|
||||
UIState.requestedChannelUrl = appLinkData
|
||||
val requestedChannelUrl = appLinkData
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterMeshReceiver()
|
||||
UIState.meshService =
|
||||
null // When our activity goes away make sure we don't keep a ptr around to the service
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
// 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() {
|
||||
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
|
||||
private fun onMeshConnectionChanged(connected: MeshService.ConnectionState) {
|
||||
UIState.isConnected.value = connected
|
||||
debug("connchange ${UIState.isConnected.value}")
|
||||
model.isConnected.value = connected
|
||||
debug("connchange ${model.isConnected.value}")
|
||||
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
|
||||
UIState.setOwner(this)
|
||||
model.setOwner(this)
|
||||
|
||||
val m = UIState.meshService!!
|
||||
val m = model.meshService!!
|
||||
|
||||
// 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
|
||||
NodeDB.nodes.clear()
|
||||
NodeDB.nodes.putAll(
|
||||
m.nodes.map
|
||||
{
|
||||
it.user?.id!! to it
|
||||
}
|
||||
)
|
||||
val nodes = m.nodes.map {
|
||||
it.user?.id!! to it
|
||||
}.toMap()
|
||||
|
||||
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 {
|
||||
debug("Received from mesh service $intent")
|
||||
override fun onReceive(context: Context, intent: Intent) =
|
||||
exceptionReporter {
|
||||
debug("Received from mesh service $intent")
|
||||
|
||||
when (intent.action) {
|
||||
MeshService.ACTION_NODE_CHANGE -> {
|
||||
val info: NodeInfo = intent.getParcelableExtra(EXTRA_NODEINFO)!!
|
||||
debug("UI nodechange $info")
|
||||
when (intent.action) {
|
||||
MeshService.ACTION_NODE_CHANGE -> {
|
||||
val info: NodeInfo =
|
||||
intent.getParcelableExtra(EXTRA_NODEINFO)!!
|
||||
debug("UI nodechange $info")
|
||||
|
||||
// We only care about nodes that have user info
|
||||
info.user?.id?.let {
|
||||
NodeDB.nodes[it] = info
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// We only care about nodes that have user info
|
||||
info.user?.id?.let {
|
||||
val newnodes = model.nodeDB.nodes.value!! + Pair(it, info)
|
||||
model.nodeDB.nodes.value = newnodes
|
||||
}
|
||||
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>({
|
||||
com.geeksville.mesh.IMeshService.Stub.asInterface(it)
|
||||
}) {
|
||||
override fun onConnected(service: com.geeksville.mesh.IMeshService) {
|
||||
UIState.meshService = service
|
||||
private
|
||||
val mesh = object :
|
||||
ServiceClient<com.geeksville.mesh.IMeshService>({
|
||||
com.geeksville.mesh.IMeshService.Stub.asInterface(it)
|
||||
}) {
|
||||
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
|
||||
registerMeshReceiver()
|
||||
|
||||
// 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)
|
||||
|
||||
debug("connected to mesh service, isConnected=${UIState.isConnected.value}")
|
||||
debug("connected to mesh service, isConnected=${model.isConnected.value}")
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
unregisterMeshReceiver()
|
||||
UIState.meshService = null
|
||||
model.meshService = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindMeshService() {
|
||||
debug("Binding to mesh service!")
|
||||
// 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)")
|
||||
|
||||
MeshService.startService(this)?.let { intent ->
|
||||
|
@ -433,11 +516,10 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
// if we never connected, do nothing
|
||||
debug("Unbinding from mesh service!")
|
||||
mesh.close()
|
||||
UIState.meshService = null
|
||||
model.meshService = null
|
||||
}
|
||||
|
||||
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
|
||||
unbindMeshService()
|
||||
|
||||
|
@ -449,9 +531,12 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
|
||||
bindMeshService()
|
||||
|
||||
val bonded = RadioInterfaceService.getBondedDeviceAddress(this) != null
|
||||
val bonded =
|
||||
RadioInterfaceService.getBondedDeviceAddress(this) != null
|
||||
/* FIXME - not yet working
|
||||
if (!bonded)
|
||||
AppStatus.currentScreen = Screen.settings
|
||||
*/
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
|
|
|
@ -3,7 +3,6 @@ package com.geeksville.mesh.model
|
|||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.compose.Model
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
|
@ -11,7 +10,6 @@ import com.journeyapps.barcodescanner.BarcodeEncoder
|
|||
import java.net.MalformedURLException
|
||||
|
||||
|
||||
@Model
|
||||
data class Channel(
|
||||
var name: String,
|
||||
var modemConfig: MeshProtos.ChannelSettings.ModemConfig,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package com.geeksville.mesh.model
|
||||
|
||||
import android.os.RemoteException
|
||||
import androidx.compose.frames.modelListOf
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.geeksville.android.BuildUtils.isEmulator
|
||||
import com.geeksville.android.Logging
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
|
@ -21,8 +21,8 @@ data class TextMessage(
|
|||
)
|
||||
|
||||
|
||||
object MessagesState : Logging {
|
||||
private val testTexts = arrayOf(
|
||||
class MessagesState(private val ui: UIViewModel) : Logging {
|
||||
private val testTexts = listOf(
|
||||
TextMessage(
|
||||
"+16508765310",
|
||||
"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 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
|
||||
fun addMessage(m: TextMessage) {
|
||||
messages.add(m)
|
||||
messages.value = messages.value!! + m
|
||||
}
|
||||
|
||||
/// Send a message and added it to our GUI log
|
||||
fun sendMessage(str: String, dest: String? = null) {
|
||||
var error: String? = null
|
||||
val service = UIState.meshService
|
||||
val service = ui.meshService
|
||||
if (service != null)
|
||||
try {
|
||||
service.sendData(
|
||||
|
@ -59,9 +62,9 @@ object MessagesState : Logging {
|
|||
else
|
||||
error = "Error: No Mesh service"
|
||||
|
||||
MessagesState.addMessage(
|
||||
addMessage(
|
||||
TextMessage(
|
||||
NodeDB.myId.value,
|
||||
ui.nodeDB.myId.value!!,
|
||||
str,
|
||||
errorMessage = error
|
||||
)
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
package com.geeksville.mesh.model
|
||||
|
||||
import androidx.compose.frames.modelMapOf
|
||||
import androidx.compose.mutableStateOf
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.geeksville.android.BuildUtils.isEmulator
|
||||
import com.geeksville.mesh.MeshUser
|
||||
import com.geeksville.mesh.NodeInfo
|
||||
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(
|
||||
Position(32.776665, -96.796989, 35), // dallas
|
||||
Position(32.960758, -96.733521, 35), // richardson
|
||||
|
@ -43,12 +44,14 @@ object NodeDB {
|
|||
private val seedWithTestNodes = isEmulator
|
||||
|
||||
/// 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
|
||||
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
|
||||
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.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.RemoteException
|
||||
import androidx.compose.mutableStateOf
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.geeksville.android.BuildUtils.isEmulator
|
||||
import com.geeksville.android.Logging
|
||||
import com.geeksville.mesh.IMeshService
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.ui.getInitials
|
||||
|
||||
/// FIXME - figure out how to merge this staate with the AppStatus Model
|
||||
object UIState : Logging {
|
||||
/// 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
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
/// Note, we generate owner initials automatically for now
|
||||
/// 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
|
||||
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
|
||||
fun setOwner(context: Context, s: String? = null) {
|
||||
|
||||
if (s != null) {
|
||||
ownerName = s
|
||||
ownerName.value = s
|
||||
|
||||
// note: we allow an empty userstring to be written to prefs
|
||||
getPreferences(context).edit(commit = true) {
|
||||
|
@ -77,15 +103,16 @@ object UIState : Logging {
|
|||
}
|
||||
|
||||
// Note: we are careful to not set a new unique ID
|
||||
if (ownerName.isNotEmpty())
|
||||
if (ownerName.value!!.isNotEmpty())
|
||||
try {
|
||||
meshService?.setOwner(
|
||||
null,
|
||||
ownerName,
|
||||
getInitials(ownerName)
|
||||
ownerName.value,
|
||||
getInitials(ownerName.value!!)
|
||||
) // Note: we use ?. here because we might be running in the emulator
|
||||
} catch (ex: RemoteException) {
|
||||
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")
|
||||
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
|
||||
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
|
||||
|
||||
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.mesh.R
|
||||
import com.geeksville.mesh.model.UIState
|
||||
|
||||
|
||||
object UILog : Logging
|
||||
|
||||
/*
|
||||
val palette = lightColorPalette() // darkColorPalette()
|
||||
|
||||
|
||||
@Composable
|
||||
fun MeshApp() {
|
||||
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"?>
|
||||
|
||||
<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"
|
||||
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>
|
||||
android:id="@+id/mapFrame"> <!-- tab layout requires a unique ID -->
|
||||
|
||||
<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>
|
||||
<string name="app_name">Meshtastic</string>
|
||||
<string name="app_name" translatable="false">Meshtastic</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>
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<style name="AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</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="windowNoTitle">true</item>
|
||||
</style>
|
||||
|
@ -17,4 +18,15 @@
|
|||
|
||||
<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>
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.3.61'
|
||||
ext.compose_version = '0.1.0-dev08'
|
||||
ext.kotlin_version = '1.3.71'
|
||||
ext.coroutines_version = "1.3.5"
|
||||
|
||||
repositories {
|
||||
|
@ -11,7 +10,7 @@ buildscript {
|
|||
mavenCentral()
|
||||
}
|
||||
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"
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
@ -23,7 +22,7 @@ buildscript {
|
|||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.0.0-beta03'
|
||||
|
||||
// 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