diff --git a/.idea/gradle.xml b/.idea/gradle.xml index dba811c87..54874b2cf 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -16,7 +16,6 @@ diff --git a/.idea/render.experimental.xml b/.idea/render.experimental.xml new file mode 100644 index 000000000..dde5d2b09 --- /dev/null +++ b/.idea/render.experimental.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 3ae0a59a7..25dbdee10 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 70757f77d..aab32d86a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,7 +34,9 @@ - + diff --git a/app/src/main/java/androidx/ui/fakeandroidview/ComposedView.kt b/app/src/main/java/androidx/ui/fakeandroidview/ComposedView.kt deleted file mode 100644 index 0c0e7e4aa..000000000 --- a/app/src/main/java/androidx/ui/fakeandroidview/ComposedView.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 549f69de2..7fb09f22b 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -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.Stub.asInterface(it) - }) { - override fun onConnected(service: com.geeksville.mesh.IMeshService) { - UIState.meshService = service + private + val mesh = object : + ServiceClient({ + 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 { diff --git a/app/src/main/java/com/geeksville/mesh/model/Channel.kt b/app/src/main/java/com/geeksville/mesh/model/Channel.kt index ce3e1cc5f..d3c0a6890 100644 --- a/app/src/main/java/com/geeksville/mesh/model/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/Channel.kt @@ -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, diff --git a/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt b/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt index c42c5ce01..3b1f1ede8 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MessagesState.kt @@ -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>(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 ) diff --git a/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt b/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt index 5802cf4b3..6313d5a8f 100644 --- a/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt +++ b/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt @@ -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(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>(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] } \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index a3fa1f6d8..1b5e7b435 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -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.DISCONNECTED) { + } + + /// various radio settings (including the channel) + val radioConfig = object : MutableLiveData(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(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("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}") } } } + diff --git a/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt index c4d2ab131..e6dddbc4c 100644 --- a/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/ui/Analytics.kt b/app/src/main/java/com/geeksville/mesh/ui/Analytics.kt deleted file mode 100644 index 57fedcb35..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/Analytics.kt +++ /dev/null @@ -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() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/AndroidImage.kt b/app/src/main/java/com/geeksville/mesh/ui/AndroidImage.kt deleted file mode 100644 index 85daaf0c8..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/AndroidImage.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/AppDrawer.kt b/app/src/main/java/com/geeksville/mesh/ui/AppDrawer.kt deleted file mode 100644 index 4de02356f..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/AppDrawer.kt +++ /dev/null @@ -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 = { } - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/BTScanScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/BTScanScreen.kt deleted file mode 100644 index 0cb0de09b..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/BTScanScreen.kt +++ /dev/null @@ -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() - - /// 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() -} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/Channel.kt deleted file mode 100644 index d8b01d8d6..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/Channel.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt new file mode 100644 index 000000000..4d8136f2f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt @@ -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 + ) + } + } + } + } +} + +*/ \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/Map.kt b/app/src/main/java/com/geeksville/mesh/ui/Map.kt deleted file mode 100644 index 1ebc95b8b..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/Map.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt new file mode 100644 index 000000000..5063a6045 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt @@ -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) { + 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) + } +} + + + + diff --git a/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt b/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt index 2641ef5c7..c835b2aa1 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt @@ -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) { } //} } +*/ \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/Messages.kt b/app/src/main/java/com/geeksville/mesh/ui/Messages.kt deleted file mode 100644 index 75e5d2981..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/Messages.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt new file mode 100644 index 000000000..600943e40 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt @@ -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() { + + /** + * 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() + + /// Called when our node DB changes + fun onMessagesChanged(nodesIn: Collection) { + 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..." + ) + } +} + + +*/ \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeInfoCard.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeInfoCard.kt deleted file mode 100644 index 132b5f732..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeInfoCard.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/ScreenFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ScreenFragment.kt new file mode 100644 index 000000000..d707ce881 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/ScreenFragment.kt @@ -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() + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/Settings.kt b/app/src/main/java/com/geeksville/mesh/ui/Settings.kt deleted file mode 100644 index f8c1da885..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/Settings.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt new file mode 100644 index 000000000..90b4ade79 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -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().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(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>(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() + } +} + diff --git a/app/src/main/java/com/geeksville/mesh/ui/Status.kt b/app/src/main/java/com/geeksville/mesh/ui/Status.kt deleted file mode 100644 index dbe4bb64c..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/Status.kt +++ /dev/null @@ -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 -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/StyledTextField.kt b/app/src/main/java/com/geeksville/mesh/ui/StyledTextField.kt deleted file mode 100644 index b3c3b4fb6..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/StyledTextField.kt +++ /dev/null @@ -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 - ) - } - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/UserIcon.kt b/app/src/main/java/com/geeksville/mesh/ui/UserIcon.kt deleted file mode 100644 index ceee5d748..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/UserIcon.kt +++ /dev/null @@ -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]) - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/Users.kt b/app/src/main/java/com/geeksville/mesh/ui/Users.kt deleted file mode 100644 index 08d49e992..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/Users.kt +++ /dev/null @@ -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) - }) - } - } */ - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt new file mode 100644 index 000000000..91c9e0e81 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -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() { + + /** + * 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() + + /// Called when our node DB changes + fun onNodesChanged(nodesIn: Collection) { + 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) + }) + } + } */ + } +} + +*/ \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/Vectors.kt b/app/src/main/java/com/geeksville/mesh/ui/Vectors.kt deleted file mode 100644 index 9aa4fb313..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/Vectors.kt +++ /dev/null @@ -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) - ) { - } - // } -} diff --git a/app/src/main/res/drawable/ic_twotone_cloud_upload_24.xml b/app/src/main/res/drawable/ic_twotone_cloud_upload_24.xml new file mode 100644 index 000000000..8982a35a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_cloud_upload_24.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/sl_lock_24dp.xml b/app/src/main/res/drawable/sl_lock_24dp.xml new file mode 100644 index 000000000..49aeeda17 --- /dev/null +++ b/app/src/main/res/drawable/sl_lock_24dp.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..faade1452 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/adapter_message_layout.xml b/app/src/main/res/layout/adapter_message_layout.xml new file mode 100644 index 000000000..1010e0ddc --- /dev/null +++ b/app/src/main/res/layout/adapter_message_layout.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/adapter_node_layout.xml b/app/src/main/res/layout/adapter_node_layout.xml new file mode 100644 index 000000000..60a894d15 --- /dev/null +++ b/app/src/main/res/layout/adapter_node_layout.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/channel_fragment.xml b/app/src/main/res/layout/channel_fragment.xml new file mode 100644 index 000000000..70a1ebcce --- /dev/null +++ b/app/src/main/res/layout/channel_fragment.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dropdown_menu_popup_item.xml b/app/src/main/res/layout/dropdown_menu_popup_item.xml new file mode 100644 index 000000000..1909d58d1 --- /dev/null +++ b/app/src/main/res/layout/dropdown_menu_popup_item.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml new file mode 100644 index 000000000..696be4e41 --- /dev/null +++ b/app/src/main/res/layout/fragment_main.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/map_view.xml b/app/src/main/res/layout/map_view.xml index a77c3ecf9..bfdb57e79 100644 --- a/app/src/main/res/layout/map_view.xml +++ b/app/src/main/res/layout/map_view.xml @@ -1,10 +1,17 @@ - + android:id="@+id/mapFrame"> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/messages_fragment.xml b/app/src/main/res/layout/messages_fragment.xml new file mode 100644 index 000000000..33a271e3f --- /dev/null +++ b/app/src/main/res/layout/messages_fragment.xml @@ -0,0 +1,40 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/nodelist_fragment.xml b/app/src/main/res/layout/nodelist_fragment.xml new file mode 100644 index 000000000..4e26df295 --- /dev/null +++ b/app/src/main/res/layout/nodelist_fragment.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/settings_fragment.xml b/app/src/main/res/layout/settings_fragment.xml new file mode 100644 index 000000000..c68655d8c --- /dev/null +++ b/app/src/main/res/layout/settings_fragment.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 14f0f21d7..cd2b1a390 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,24 @@ - Meshtastic + Meshtastic Settings + Channel Name + Channel options + Share button + QR code + Unset + Connection status + application icon + Unknown Username + User avatar + 2.13 km + hey I found the cache, it is over here next to the big tiger. I\'m kinda scared. + Some Username + Send Text + 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. + Username unset + Your Name + Anonymous usage statistics and crash reports. + Looking for Meshtastic devices... + Meshtastic_ac23 + Meshtastic_1267 diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 5e399fa2e..3431ac63d 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,14 +1,15 @@ - - @@ -17,4 +18,15 @@ + + diff --git a/build.gradle b/build.gradle index c00cf0268..7c9083db8 100644 --- a/build.gradle +++ b/build.gradle @@ -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' } } diff --git a/geeksville-androidlib b/geeksville-androidlib index 65f39f90c..ebc40c05f 160000 --- a/geeksville-androidlib +++ b/geeksville-androidlib @@ -1 +1 @@ -Subproject commit 65f39f90ce365263620d5f9cbddca0c8abebcf9a +Subproject commit ebc40c05fd8c30aabb3070468627e5fe6ae59cd5