diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt index 7d2723e..0d25797 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt @@ -16,7 +16,6 @@ object KnownApps { "in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics "com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches "com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well - "com.mapquest.android.ace", // MapQuest: map, search, routing - works well "nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well ) @@ -68,6 +67,7 @@ object KnownApps { /** Shown on the launcher with a yellow mark: starts and can be used, but not cleanly enough to call working. */ private val partial = setOf( "app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet + "com.mapquest.android.ace", // MapQuest: map, search and routing run, but not cleanly ) fun isPartial(packageName: String): Boolean = packageName in partial diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt index 4d11e69..b53c99f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt @@ -38,7 +38,7 @@ import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.CertProvider -private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, HELP, WHATS_NEW } +private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, HELP, WHATS_NEW, RECOMMENDED_APPS } /** Three screens and a back stack of depth one: no navigation library needed. */ @Composable @@ -57,7 +57,9 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) { }, ) } - BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME } + BackHandler(enabled = screen != Screen.HOME) { + screen = if (screen == Screen.RECOMMENDED_APPS) Screen.SETTINGS else Screen.HOME + } // Home stays upright; every other screen turns with the phone. LaunchedEffect(screen) { @@ -98,7 +100,11 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) { ) Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.HOME }) Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME }) - Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME }) + Screen.SETTINGS -> SettingsScreen( + onBack = { screen = Screen.HOME }, + onOpenRecommendedApps = { screen = Screen.RECOMMENDED_APPS }, + ) + Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS }) Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME }) Screen.HELP -> HelpScreen( onBack = { screen = Screen.HOME }, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt new file mode 100644 index 0000000..df70321 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt @@ -0,0 +1,161 @@ +package app.seb3thehacker.gearslip.ui + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Drawable +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap + +/** A free app that runs on Gearslip's car screen, and where to get it. */ +private class RecommendedApp(val name: String, val packageName: String, val url: String, val about: String) + +private val recommended = listOf( + "Maps" to listOf( + RecommendedApp("Vela Maps", "app.vela", "https://github.com/PimpinPumpkin/Vela", "Turn-by-turn navigation without Google."), + RecommendedApp("Organic Maps", "app.organicmaps", "https://github.com/organicmaps/organicmaps", "Offline maps and routing."), + ), + "Music" to listOf( + RecommendedApp("ViVi Music", "com.vivi.vivimusic", "https://github.com/vivizzz007/vivi-music", "A YouTube Music player."), + RecommendedApp("Metrolist", "com.metrolist.music", "https://github.com/MetrolistGroup/Metrolist", "A YouTube Music player."), + ), +) + +/** Opened from Settings: the same list the setup guide ends with. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecommendedAppsScreen(onBack: () -> Unit) { + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + TopAppBar( + title = { Text("Recommended apps") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + RECOMMENDED_INTRO, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + RecommendedAppsList() + Spacer(Modifier.height(24.dp)) + } + } +} + +internal const val RECOMMENDED_INTRO = + "These free apps run on Gearslip's car screen. Each button opens the app's page on GitHub." + +/** The same green as the launcher's "works" check. */ +private val INSTALLED_GREEN = Color(0xFF43A047) + +/** The apps by section, each with a GitHub button. Installed ones say so in green. */ +@Composable +internal fun RecommendedAppsList(modifier: Modifier = Modifier) { + val context = LocalContext.current + Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) { + for ((section, apps) in recommended) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + section, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + for (app in apps) AppRow(app) { openPage(context, app.url) } + } + } + } +} + +@Composable +private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) { + val context = LocalContext.current + val icon = remember(app.packageName) { installedIcon(context, app.packageName) } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + if (icon != null) { + Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize()) + } else { + Text(app.name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + } + Column(Modifier.weight(1f).padding(horizontal = 14.dp)) { + Text(app.name, style = MaterialTheme.typography.titleMedium) + Text(app.about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (icon != null) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) { + Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = INSTALLED_GREEN, modifier = Modifier.size(16.dp)) + Text( + "Installed", + style = MaterialTheme.typography.labelLarge, + color = INSTALLED_GREEN, + modifier = Modifier.padding(start = 6.dp), + ) + } + } + } + OutlinedButton(onClick = onOpen) { Text("GitHub") } + } +} + +private fun installedIcon(context: Context, packageName: String): Drawable? = + runCatching { context.packageManager.getApplicationIcon(packageName) }.getOrNull() + +private fun openPage(context: Context, url: String) { + runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index 24e8fd5..7bc37f0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -54,7 +54,7 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen(onBack: () -> Unit) { +fun SettingsScreen(onBack: () -> Unit, onOpenRecommendedApps: () -> Unit) { Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { @@ -78,6 +78,7 @@ fun SettingsScreen(onBack: () -> Unit) { ) { Spacer(Modifier.height(0.dp)) GearslipEnabledSection() + AppsSection(onOpenRecommendedApps) CertificateSection() Spacer(Modifier.height(16.dp)) } @@ -119,6 +120,16 @@ private fun GearslipEnabledSection() { } } +@Composable +private fun AppsSection(onOpenRecommendedApps: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionTitle("Apps") + OutlinedButton(onClick = onOpenRecommendedApps, modifier = Modifier.fillMaxWidth().height(52.dp)) { + Text("Recommended apps") + } + } +} + @Composable private fun CertificateSection() { val context = LocalContext.current diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt index 711a2a1..628dc0b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt @@ -61,6 +61,8 @@ private class GuidePage( val command: String? = null, val actionLabel: String? = null, val onAction: (() -> Unit)? = null, + /** Drawn under [explanation], inside the card. */ + val content: (@Composable () -> Unit)? = null, ) /** True once every permission in [permissions] is already granted - nothing left for this page to do. */ @@ -261,6 +263,15 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List Unit, onDone: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) page.command?.let { CopyableCommand(it) } + page.content?.invoke() } } @@ -372,7 +384,9 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) { } else -> { PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) { - Button(onClick = { index += 1 }, modifier = it) { Text("Next") } + Button(onClick = { index += 1 }, modifier = it) { + Text(if (index == pages.lastIndex) "Done" else "Next") + } } } }