diff --git a/TODO.md b/TODO.md index 32e103b0b..0d6c67e1b 100644 --- a/TODO.md +++ b/TODO.md @@ -6,6 +6,7 @@ MVP features required for first public alpha * parcels are busted - something wrong with the Parcelize kotlin magic * all chat in the app defaults to group chat * make my android app show mesh state +* add app icon * when notified phone should automatically download messages * at connect we might receive messages before finished downloading the nodeinfo. In that case, process those messages later * use https://codelabs.developers.google.com/codelabs/jetpack-compose-basics/#4 to show service state @@ -14,6 +15,7 @@ MVP features required for first public alpha * call crashlytics from exceptionReporter!!! currently not logging failures caught there * show direction and distance on the nodeinfo cards * test with oldest compatible android in emulator (see below for testing with hardware) +* make playstore entry # Signal alpha release Do this "Signal app compatible" release relatively soon after the alpha release of the android app. diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index a57fc8f36..7bcd10ca5 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -12,92 +12,27 @@ import android.os.IBinder import android.view.Menu import android.view.MenuItem import android.widget.Toast -import androidx.annotation.DrawableRes import androidx.appcompat.app.AppCompatActivity -import androidx.compose.Composable -import androidx.compose.Model -import androidx.compose.mutableStateOf -import androidx.compose.state import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat -import androidx.ui.animation.Crossfade -import androidx.ui.core.Modifier -import androidx.ui.core.Text -import androidx.ui.core.WithDensity import androidx.ui.core.setContent -import androidx.ui.foundation.Clickable -import androidx.ui.foundation.VerticalScroller -import androidx.ui.foundation.shape.corner.RoundedCornerShape -import androidx.ui.graphics.Color -import androidx.ui.graphics.vector.DrawVector -import androidx.ui.layout.* -import androidx.ui.material.* -import androidx.ui.material.ripple.Ripple -import androidx.ui.material.surface.Surface -import androidx.ui.res.vectorResource -import androidx.ui.tooling.preview.Preview -import androidx.ui.unit.dp import com.geeksville.android.Logging +import com.geeksville.mesh.ui.MeshApp +import com.geeksville.mesh.ui.TextMessage +import com.geeksville.mesh.ui.UIState import com.geeksville.util.exceptionReporter import com.google.firebase.crashlytics.FirebaseCrashlytics import java.nio.charset.Charset import java.util.* -// defines the screens we have in the app -sealed class Screen { - object Home : Screen() - object Settings : Screen() -} - -@Model -object AppStatus { - var currentScreen: Screen = Screen.Home -} - -/** - * Temporary solution pending navigation support. - */ -fun navigateTo(destination: Screen) { - AppStatus.currentScreen = destination -} - class MainActivity : AppCompatActivity(), Logging { companion object { const val REQUEST_ENABLE_BT = 10 const val DID_REQUEST_PERM = 11 - - private val testPositions = arrayOf( - Position(32.776665, -96.796989, 35), // dallas - Position(32.960758, -96.733521, 35), // richardson - Position(32.912901, -96.781776, 35) // north dallas - ) - - private val testNodes = testPositions.mapIndexed { index, it -> - NodeInfo( - 9 + index, - MeshUser("+65087653%02d".format(9 + index), "Kevin Mester$index", "KM$index"), - it, - 12345 - ) - } - - data class TextMessage(val date: Date, val from: String, val text: String) - - private val testTexts = listOf( - TextMessage(Date(), "+6508675310", "I found the cache"), - TextMessage(Date(), "+6508675311", "Help! I've fallen and I can't get up.") - ) } - /// A map from nodeid to to nodeinfo - private val nodes = mutableStateOf(testNodes.map { it.user!!.id to it }.toMap()) - - private val messages = mutableStateOf(testTexts) - - /// Are we connected to our radio device - private var isConnected = mutableStateOf(false) private val utf8 = Charset.forName("UTF-8") @@ -169,220 +104,6 @@ class MainActivity : AppCompatActivity(), Logging { } } - @Composable - fun composeNodeInfo(it: NodeInfo) { - Text("Node: ${it.user?.longName}") - } - - @Composable - fun VectorImageButton(@DrawableRes id: Int, onClick: () -> Unit) { - Ripple(bounded = false) { - Clickable(onClick = onClick) { - VectorImage(id = id) - } - } - } - - @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) - } - } - } - - @Composable - fun HomeContent() { - Column { - Text(text = "Meshtastic") - - Text("Radio connected: ${isConnected.value}") - - nodes.value.values.forEach { - composeNodeInfo(it) - } - - messages.value.forEach { - Text("Text: ${it.text}") - } - - Button(text = "Start scan", - onClick = { - if (bluetoothAdapter != null) { - // Note: We don't want this service to die just because our activity goes away (because it is doing a software update) - // So we use the application context instead of the activity - SoftwareUpdateService.enqueueWork( - applicationContext, - SoftwareUpdateService.startUpdateIntent - ) - } - }) - - Button(text = "send packets", - onClick = { sendTestPackets() }) - } - } - - @Composable - fun HomeScreen(openDrawer: () -> Unit) { - Column { - TopAppBar( - title = { Text(text = "Meshtastic") }, - navigationIcon = { - VectorImageButton(R.drawable.ic_launcher_foreground) { - openDrawer() - } - } - ) - VerticalScroller(modifier = LayoutFlexible(1f)) { - HomeContent() - } - } - } - - @Composable - fun composeView() { - val (drawerState, onDrawerStateChange) = state { DrawerState.Closed } - - MaterialTheme { - ModalDrawerLayout( - drawerState = drawerState, - onStateChange = onDrawerStateChange, - gesturesEnabled = drawerState == DrawerState.Opened, - drawerContent = { - - AppDrawer( - currentScreen = AppStatus.currentScreen, - closeDrawer = { onDrawerStateChange(DrawerState.Closed) } - ) - - /* - // modifier = Spacing(8.dp) - Column() { - - - */ - }, bodyContent = { AppContent { onDrawerStateChange(DrawerState.Opened) } }) - } - } - - @Preview - @Composable - fun previewView() { - // It seems modaldrawerlayout not yet supported in preview - HomeContent() - } - - @Composable - private fun AppContent(openDrawer: () -> Unit) { - Crossfade(AppStatus.currentScreen) { screen -> - Surface(color = (MaterialTheme.colors()).background) { - when (screen) { - is Screen.Home -> HomeScreen { openDrawer() } - /* is Screen.Interests -> InterestsScreen { openDrawer() } - is Screen.Article -> ArticleScreen(postId = screen.postId) */ - } - } - } - } - - @Composable - private fun AppDrawer( - currentScreen: Screen, - closeDrawer: () -> Unit - ) { - Column(modifier = LayoutSize.Fill) { - Spacer(LayoutHeight(24.dp)) - Row(modifier = LayoutPadding(16.dp)) { - VectorImage( - id = R.drawable.ic_launcher_foreground, - tint = (MaterialTheme.colors()).primary - ) - Spacer(LayoutWidth(8.dp)) - VectorImage(id = R.drawable.ic_launcher_foreground) - } - Divider(color = Color(0x14333333)) - DrawerButton( - icon = R.drawable.ic_launcher_foreground, - label = "Home", - isSelected = currentScreen == Screen.Home - ) { - navigateTo(Screen.Home) - closeDrawer() - } - - /* - DrawerButton( - icon = R.drawable.ic_interests, - label = "Interests", - isSelected = currentScreen == Screen.Interests - ) { - navigateTo(Screen.Interests) - closeDrawer() - } - */ - } - } - - @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( - left = 8.dp, - top = 8.dp, - right = 8.dp, - bottom = 0.dp - ), - color = backgroundColor, - shape = RoundedCornerShape(4.dp) - ) { - Button(onClick = action, style = TextButtonStyle()) { - Row { - VectorImage( - modifier = LayoutGravity.Center, - id = icon, - tint = textIconColor - ) - Spacer(LayoutWidth(16.dp)) - Text( - text = label, - style = (MaterialTheme.typography()).body2.copy( - color = textIconColor - ) - ) - } - } - } - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -393,7 +114,7 @@ class MainActivity : AppCompatActivity(), Logging { FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(true) setContent { - composeView() + MeshApp() } // Ensures Bluetooth is available on the device and it is enabled. If not, @@ -432,9 +153,9 @@ class MainActivity : AppCompatActivity(), Logging { // We only care about nodes that have user info info.user?.id?.let { - val newnodes = nodes.value.toMutableMap() + val newnodes = UIState.nodes.value.toMutableMap() newnodes[it] = info - nodes.value = newnodes + UIState.nodes.value = newnodes } } @@ -447,16 +168,16 @@ class MainActivity : AppCompatActivity(), Logging { when (typ) { MeshProtos.Data.Type.CLEAR_TEXT_VALUE -> { // FIXME - use the real time from the packet - val modded = messages.value.toMutableList() + val modded = UIState.messages.value.toMutableList() modded.add(TextMessage(Date(), sender, payload.toString(utf8))) - messages.value = modded + UIState.messages.value = modded } else -> TODO() } } RadioInterfaceService.CONNECTCHANGED_ACTION -> { - isConnected.value = intent.getBooleanExtra(EXTRA_CONNECTED, false) - debug("connchange $isConnected") + UIState.isConnected.value = intent.getBooleanExtra(EXTRA_CONNECTED, false) + debug("connchange ${UIState.isConnected.value}") } else -> TODO() } @@ -475,10 +196,10 @@ class MainActivity : AppCompatActivity(), Logging { // FIXME - do actions for when we connect to the service debug("did connect") - isConnected.value = m.isConnected + UIState.isConnected.value = m.isConnected // make some placeholder nodeinfos - nodes.value = + UIState.nodes.value = m.online.toList().map { it to NodeInfo(0, MeshUser(it, "unknown", "unk")) }.toMap() } diff --git a/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt b/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt new file mode 100644 index 000000000..e90d6b607 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt @@ -0,0 +1,201 @@ +package com.geeksville.mesh.ui + +import androidx.annotation.DrawableRes +import androidx.compose.Composable +import androidx.compose.state +import androidx.ui.animation.Crossfade +import androidx.ui.core.Modifier +import androidx.ui.core.Text +import androidx.ui.foundation.VerticalScroller +import androidx.ui.foundation.shape.corner.RoundedCornerShape +import androidx.ui.graphics.Color +import androidx.ui.layout.* +import androidx.ui.material.* +import androidx.ui.material.surface.Surface +import androidx.ui.tooling.preview.Preview +import androidx.ui.unit.dp +import com.geeksville.mesh.R + + +@Composable +fun HomeContent() { + Column { + Text(text = "Meshtastic") + + Text("Radio connected: ${UIState.isConnected.value}") + + UIState.nodes.value.values.forEach { + NodeInfoCard(it) + } + + UIState.messages.value.forEach { + Text("Text: ${it.text}") + } + + /* + Button(text = "Start scan", + onClick = { + if (bluetoothAdapter != null) { + // Note: We don't want this service to die just because our activity goes away (because it is doing a software update) + // So we use the application context instead of the activity + SoftwareUpdateService.enqueueWork( + applicationContext, + SoftwareUpdateService.startUpdateIntent + ) + } + }) + + Button(text = "send packets", + onClick = { sendTestPackets() }) */ + } +} + +@Composable +fun HomeScreen(openDrawer: () -> Unit) { + Column { + TopAppBar( + title = { Text(text = "Meshtastic") }, + navigationIcon = { + VectorImageButton(R.drawable.ic_launcher_foreground) { + openDrawer() + } + } + ) + VerticalScroller(modifier = LayoutFlexible(1f)) { + HomeContent() + } + } +} + +@Composable +fun MeshApp() { + val (drawerState, onDrawerStateChange) = state { DrawerState.Closed } + + MaterialTheme { + ModalDrawerLayout( + drawerState = drawerState, + onStateChange = onDrawerStateChange, + gesturesEnabled = drawerState == DrawerState.Opened, + drawerContent = { + + AppDrawer( + currentScreen = AppStatus.currentScreen, + closeDrawer = { onDrawerStateChange(DrawerState.Closed) } + ) + + /* + // modifier = Spacing(8.dp) + Column() { + + + */ + }, bodyContent = { AppContent { onDrawerStateChange(DrawerState.Opened) } }) + } +} + +@Preview +@Composable +fun previewView() { + // It seems modaldrawerlayout not yet supported in preview + HomeContent() +} + +@Composable +private fun AppContent(openDrawer: () -> Unit) { + Crossfade(AppStatus.currentScreen) { screen -> + Surface(color = (MaterialTheme.colors()).background) { + when (screen) { + is Screen.Home -> HomeScreen { openDrawer() } + /* is Screen.Interests -> InterestsScreen { openDrawer() } + is Screen.Article -> ArticleScreen(postId = screen.postId) */ + } + } + } +} + +@Composable +private fun AppDrawer( + currentScreen: Screen, + closeDrawer: () -> Unit +) { + Column(modifier = LayoutSize.Fill) { + Spacer(LayoutHeight(24.dp)) + Row(modifier = LayoutPadding(16.dp)) { + VectorImage( + id = R.drawable.ic_launcher_foreground, + tint = (MaterialTheme.colors()).primary + ) + Spacer(LayoutWidth(8.dp)) + VectorImage(id = R.drawable.ic_launcher_foreground) + } + Divider(color = Color(0x14333333)) + DrawerButton( + icon = R.drawable.ic_launcher_foreground, + label = "Home", + isSelected = currentScreen == Screen.Home + ) { + navigateTo(Screen.Home) + closeDrawer() + } + + /* + DrawerButton( + icon = R.drawable.ic_interests, + label = "Interests", + isSelected = currentScreen == Screen.Interests + ) { + navigateTo(Screen.Interests) + closeDrawer() + } + */ + } +} + +@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( + left = 8.dp, + top = 8.dp, + right = 8.dp, + bottom = 0.dp + ), + color = backgroundColor, + shape = RoundedCornerShape(4.dp) + ) { + Button(onClick = action, style = TextButtonStyle()) { + Row { + VectorImage( + modifier = LayoutGravity.Center, + id = icon, + tint = textIconColor + ) + Spacer(LayoutWidth(16.dp)) + Text( + text = label, + style = (MaterialTheme.typography()).body2.copy( + color = textIconColor + ) + ) + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeInfoCard.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeInfoCard.kt new file mode 100644 index 000000000..bbe3e9bf1 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeInfoCard.kt @@ -0,0 +1,67 @@ +package com.geeksville.mesh.ui + +import androidx.compose.Composable +import androidx.ui.core.Modifier +import androidx.ui.core.Text +import androidx.ui.foundation.DrawImage +import androidx.ui.layout.Container +import androidx.ui.layout.LayoutPadding +import androidx.ui.layout.LayoutSize +import androidx.ui.layout.Row +import androidx.ui.material.EmphasisLevels +import androidx.ui.material.MaterialTheme +import androidx.ui.material.ProvideEmphasis +import androidx.ui.res.imageResource +import androidx.ui.tooling.preview.Preview +import androidx.ui.unit.dp +import com.geeksville.mesh.NodeInfo +import com.geeksville.mesh.R + + +@Composable +fun NodeIcon(modifier: Modifier = Modifier.None, node: NodeInfo) { + val image = imageResource(R.drawable.ic_launcher_foreground) + + Container(modifier = modifier + LayoutSize(40.dp, 40.dp)) { + DrawImage(image) + } +} + +@Composable +fun NodeHeading(node: NodeInfo) { + ProvideEmphasis(emphasis = EmphasisLevels().high) { + Text(node.user?.longName ?: "unknown", style = MaterialTheme.typography().subtitle1) + } +} + +/** + * An info card for a node: + * + * on left, the icon for the user (or shortname if that is all we have) + * + * Middle is users fullname + * + * on right a compass rose with a pointer to the user and distance + * + */ +@Composable +fun NodeInfoCard(node: NodeInfo) { + // Text("Node: ${it.user?.longName}") + Row(modifier = LayoutPadding(16.dp)) { + NodeIcon( + modifier = LayoutPadding(left = 0.dp, top = 0.dp, right = 16.dp, bottom = 0.dp), + node = node + ) + + NodeHeading(node) + + // FIXME - show compass instead + NodeIcon(node = node) + } +} + +@Preview +@Composable +fun nodeInfoPreview() { + NodeInfoCard(UIState.testNodes[0]) +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/Status.kt b/app/src/main/java/com/geeksville/mesh/ui/Status.kt new file mode 100644 index 000000000..783943874 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/Status.kt @@ -0,0 +1,61 @@ +package com.geeksville.mesh.ui + +import androidx.compose.Model +import androidx.compose.mutableStateOf +import com.geeksville.mesh.MeshUser +import com.geeksville.mesh.NodeInfo +import com.geeksville.mesh.Position +import java.util.* + +// defines the screens we have in the app +sealed class Screen { + object Home : Screen() + // object Settings : Screen() +} + +@Model +object AppStatus { + var currentScreen: Screen = Screen.Home +} + +data class TextMessage(val date: Date, val from: String, val text: String) + +/// FIXME - figure out how to merge this staate with the AppStatus Model +object UIState { + + private val testPositions = arrayOf( + Position(32.776665, -96.796989, 35), // dallas + Position(32.960758, -96.733521, 35), // richardson + Position(32.912901, -96.781776, 35) // north dallas + ) + + val testNodes = testPositions.mapIndexed { index, it -> + NodeInfo( + 9 + index, + MeshUser("+65087653%02d".format(9 + index), "Kevin Mester$index", "KM$index"), + it, + 12345 + ) + } + + val testTexts = listOf( + TextMessage(Date(), "+6508675310", "I found the cache"), + TextMessage(Date(), "+6508675311", "Help! I've fallen and I can't get up.") + ) + + /// A map from nodeid to to nodeinfo + val nodes = mutableStateOf(testNodes.map { it.user!!.id to it }.toMap()) + + val messages = mutableStateOf(testTexts) + + /// Are we connected to our radio device + var isConnected = mutableStateOf(false) + +} + +/** + * Temporary solution pending navigation support. + */ +fun navigateTo(destination: Screen) { + AppStatus.currentScreen = destination +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/Vectors.kt b/app/src/main/java/com/geeksville/mesh/ui/Vectors.kt new file mode 100644 index 000000000..eb1b3fcfd --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/Vectors.kt @@ -0,0 +1,41 @@ +package com.geeksville.mesh.ui + +import androidx.annotation.DrawableRes +import androidx.compose.Composable +import androidx.ui.core.Modifier +import androidx.ui.core.WithDensity +import androidx.ui.foundation.Clickable +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.ripple.Ripple +import androidx.ui.res.vectorResource + + +@Composable +fun VectorImageButton(@DrawableRes id: Int, onClick: () -> Unit) { + Ripple(bounded = false) { + Clickable(onClick = onClick) { + VectorImage(id = id) + } + } +} + +@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) + } + } +}