reccomend apps to install during setup

This commit is contained in:
Seb3thehacker committed 2026-10-02 15:28:54 -07:00
1 parent 1b44d2d798
commit 96d16d048c
5 files changed
+198 -6

No files matched your search

@@ -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
@@ -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 },
@@ -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))
}
}
@@ -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
@@ -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<Gu
),
)
}
// --- Last: apps worth installing, once everything above is set up ---------------
add(
GuidePage(
title = "Recommended apps",
explanation = RECOMMENDED_INTRO,
content = { RecommendedAppsList() },
),
)
}
/**
@@ -336,6 +347,7 @@ fun SetupGuideScreen(onRequestCallScreening: () -> 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")
}
}
}
}