mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
ask once to share after the first drive that works, cache the cert for settings
This commit is contained in:
1 parent
40086e061b
commit
516f6c7f94
10 files changed
+424
-59
No files matched your search
@@ -160,6 +160,11 @@
|
||||
android:name=".notify.TestMessageReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- The one-time "Gearslip worked with your car" ask: its Share and Not now buttons. -->
|
||||
<receiver
|
||||
android:name=".stats.WorkedPromptReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".mirror.MirrorService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -220,6 +220,9 @@ object SessionReport {
|
||||
runCatching {
|
||||
UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi, sessionNote())
|
||||
}
|
||||
if (outcome == "connected") runCatching {
|
||||
app.seb3thehacker.gearslip.stats.WorkedPrompt.onDriveWorked(context, headUnitInfo, protocolVersion, carScreen, carDpi)
|
||||
}
|
||||
runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) }
|
||||
}
|
||||
GearslipLog.flush()
|
||||
|
||||
@@ -80,6 +80,7 @@ object UsageStats {
|
||||
}
|
||||
|
||||
fun set(context: Context, enabled: Boolean, choices: Choices = choices(context)) {
|
||||
val wasEnabled = enabled(context)
|
||||
prefs(context).edit()
|
||||
.putBoolean(KEY_ENABLED, enabled)
|
||||
.putBoolean(KEY_ASKED, true)
|
||||
@@ -93,6 +94,8 @@ object UsageStats {
|
||||
if (!enabled) prefs(context).edit().remove(KEY_CARS).remove(KEY_LAST_CARS).remove(KEY_SENT_DAY)
|
||||
.remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply()
|
||||
if (!choices.failures) prefs(context).edit().remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply()
|
||||
// Turned on anywhere - Settings, Home, or the ask itself - answers the one-time ask.
|
||||
if (enabled && !wasEnabled) WorkedPrompt.onSharingOn(context)
|
||||
}
|
||||
|
||||
/** The driver left the screen with the usage note choices: send today's note if it's due. */
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package app.seb3thehacker.gearslip.stats
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import app.seb3thehacker.gearslip.GearslipActivity
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The one-time "Gearslip worked with your car. Share that?" ask, for drivers who don't share
|
||||
* usage notes. It comes after the first drive that worked, when a driver is happiest to help, as
|
||||
* a notification the moment the car leaves and as a card on Home. Answering either clears both,
|
||||
* and it never comes back.
|
||||
*
|
||||
* Saying yes turns on the same usage notes switch as Settings, and that drive's car goes in the
|
||||
* first note. Until the driver answers, the car waits here on the phone and is sent nowhere.
|
||||
*/
|
||||
object WorkedPrompt {
|
||||
|
||||
private const val PREFS = "worked_prompt"
|
||||
private const val KEY_DONE = "done"
|
||||
private const val KEY_CAR = "car"
|
||||
private const val CHANNEL = "worked"
|
||||
private const val NOTIFICATION_ID = 4_311
|
||||
private const val ACTION_SHARE = "app.seb3thehacker.gearslip.WORKED_SHARE"
|
||||
private const val ACTION_NOT_NOW = "app.seb3thehacker.gearslip.WORKED_NOT_NOW"
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
private var loaded = false
|
||||
private val flow = MutableStateFlow<String?>(null)
|
||||
|
||||
/** The car's name while the ask is waiting for an answer, otherwise null. */
|
||||
fun pending(context: Context): StateFlow<String?> {
|
||||
if (!loaded) {
|
||||
loaded = true
|
||||
flow.value = saved(context)?.let(::carName)
|
||||
}
|
||||
return flow
|
||||
}
|
||||
|
||||
/** A drive that reached the car's screen ended cleanly. Asks, if it's never asked before. */
|
||||
fun onDriveWorked(context: Context, info: ServiceDiscovery.HeadUnitInfo?, protocol: String, screen: String, dpi: Int) {
|
||||
val p = prefs(context)
|
||||
if (UsageStats.enabled(context) || p.getBoolean(KEY_DONE, false) || p.contains(KEY_CAR)) return
|
||||
val car = JSONObject()
|
||||
.put("name", info?.headUnitName.orEmpty())
|
||||
.put("car", info?.carModel.orEmpty())
|
||||
.put("year", info?.carYear.orEmpty())
|
||||
.put("make", info?.headUnitMake.orEmpty())
|
||||
.put("model", info?.headUnitModel.orEmpty())
|
||||
.put("protocol", protocol)
|
||||
.put("screen", screen)
|
||||
.put("dpi", dpi)
|
||||
p.edit().putString(KEY_CAR, car.toString()).apply()
|
||||
loaded = true
|
||||
flow.value = carName(car)
|
||||
notify(context, carName(car))
|
||||
}
|
||||
|
||||
/**
|
||||
* True once a driver turns sharing on from the ask, until Home has thrown its confetti. It
|
||||
* waits for Home: a Share tapped in the notification celebrates the next time Gearslip opens.
|
||||
*/
|
||||
private val celebrateFlow = MutableStateFlow(false)
|
||||
val celebrate: StateFlow<Boolean> = celebrateFlow
|
||||
|
||||
fun celebrated() { celebrateFlow.value = false }
|
||||
|
||||
/** The driver answered, here or on Home. Either way it's never asked again. */
|
||||
fun answer(context: Context, share: Boolean) {
|
||||
if (share) {
|
||||
// Turning sharing on lands in [onSharingOn], which clears the ask.
|
||||
UsageStats.set(context, true)
|
||||
return
|
||||
}
|
||||
clear(context)
|
||||
GearslipLog.i("worked prompt: not now")
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage notes were just turned on, from the ask or anywhere else. With the ask still waiting,
|
||||
* that answers it, and the car it was about goes in the first note.
|
||||
*/
|
||||
internal fun onSharingOn(context: Context) {
|
||||
val car = saved(context) ?: return
|
||||
clear(context)
|
||||
celebrateFlow.value = true
|
||||
GearslipLog.i("worked prompt: usage notes turned on")
|
||||
val info = ServiceDiscovery.HeadUnitInfo(
|
||||
car.optString("name"), car.optString("car"), car.optString("year"),
|
||||
car.optString("make"), car.optString("model"),
|
||||
)
|
||||
UsageStats.recordSession(
|
||||
context, info, car.optString("protocol"), "connected",
|
||||
car.optString("screen"), car.optInt("dpi"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun clear(context: Context) {
|
||||
prefs(context).edit().remove(KEY_CAR).putBoolean(KEY_DONE, true).apply()
|
||||
loaded = true
|
||||
flow.value = null
|
||||
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun saved(context: Context): JSONObject? =
|
||||
prefs(context).getString(KEY_CAR, null)?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
|
||||
/** "your 2019 Clio", "your Renault Media Nav", or "your car" when it didn't say. */
|
||||
private fun carName(car: JSONObject): String {
|
||||
val model = car.optString("car").trim()
|
||||
val year = car.optString("year").trim().takeIf { it.isNotEmpty() && it != "0" }
|
||||
val unit = "${car.optString("make")} ${car.optString("model")}".trim()
|
||||
return when {
|
||||
model.isNotEmpty() -> "your " + listOfNotNull(year, model).joinToString(" ")
|
||||
unit.isNotEmpty() -> "your $unit"
|
||||
else -> "your car"
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(context: Context, car: String) {
|
||||
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, "After a drive", NotificationManager.IMPORTANCE_DEFAULT),
|
||||
)
|
||||
val open = PendingIntent.getActivity(
|
||||
context, NOTIFICATION_ID,
|
||||
Intent(context, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val text = "Share that so the next driver knows it works? No name, account, or location."
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_send)
|
||||
.setContentTitle(TITLE_START + car)
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(true)
|
||||
.addAction(0, "Not now", broadcast(context, ACTION_NOT_NOW))
|
||||
.addAction(0, "Share", broadcast(context, ACTION_SHARE))
|
||||
.build()
|
||||
runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) }
|
||||
.onSuccess { GearslipLog.i("worked prompt: asked after the first good drive") }
|
||||
.onFailure { GearslipLog.w("worked prompt: could not post the notification (${it.message})") }
|
||||
}
|
||||
|
||||
private fun broadcast(context: Context, action: String): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context, action.hashCode(),
|
||||
Intent(context, WorkedPromptReceiver::class.java).setAction(action),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
internal fun onAction(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
ACTION_SHARE -> answer(context, share = true)
|
||||
ACTION_NOT_NOW -> answer(context, share = false)
|
||||
}
|
||||
}
|
||||
|
||||
const val TITLE_START = "Gearslip worked with "
|
||||
}
|
||||
|
||||
/** The notification's Share and Not now buttons land here. */
|
||||
class WorkedPromptReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) = WorkedPrompt.onAction(context, intent)
|
||||
}
|
||||
@@ -14,8 +14,17 @@ data class CertSummary(
|
||||
val validUntil: String?,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* The last [read], so Settings draws the certificate row at once instead of a second
|
||||
* later, which shifted everything under it. Null until the first read.
|
||||
*/
|
||||
@Volatile var cached: CertSummary? = null
|
||||
private set
|
||||
|
||||
/** Blocking (parses a key store): call from a background dispatcher. */
|
||||
fun read(context: Context): CertSummary {
|
||||
fun read(context: Context): CertSummary = readNow(context).also { cached = it }
|
||||
|
||||
private fun readNow(context: Context): CertSummary {
|
||||
val identity = CertProvider.loadSupplied(context)
|
||||
?: return CertSummary(
|
||||
CertProvider.Kind.SELF_SIGNED,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
private class Piece(
|
||||
val x: Float, // across the screen, 0..1
|
||||
val delay: Float, // seconds before it starts falling
|
||||
val speed: Float, // screen heights per second
|
||||
val sway: Float, // how far it drifts side to side, in screen widths
|
||||
val swaySpeed: Float,
|
||||
val spin: Float, // degrees per second
|
||||
val width: Float, // dp
|
||||
val height: Float,
|
||||
val color: Color,
|
||||
)
|
||||
|
||||
/**
|
||||
* Confetti falling from the top of the screen once, for about three seconds. Draws over whatever
|
||||
* is under it and takes no touches; [onDone] fires when the last piece has left the screen.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConfettiFall(onDone: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val colors = listOf(
|
||||
scheme.primary, scheme.tertiary, scheme.secondary,
|
||||
Color(0xFFFFC107), Color(0xFFE91E63), Color(0xFF4CAF50), Color(0xFF2196F3),
|
||||
)
|
||||
val pieces = remember {
|
||||
List(PIECES) {
|
||||
Piece(
|
||||
x = Random.nextFloat(),
|
||||
delay = Random.nextFloat() * 0.9f,
|
||||
speed = 0.35f + Random.nextFloat() * 0.35f,
|
||||
sway = 0.01f + Random.nextFloat() * 0.03f,
|
||||
swaySpeed = 2f + Random.nextFloat() * 4f,
|
||||
spin = (Random.nextFloat() - 0.5f) * 720f,
|
||||
width = 4f + Random.nextFloat() * 4f,
|
||||
height = 8f + Random.nextFloat() * 6f,
|
||||
color = colors[Random.nextInt(colors.size)],
|
||||
)
|
||||
}
|
||||
}
|
||||
var t by remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(Unit) {
|
||||
val start = withFrameNanos { it }
|
||||
// The slowest piece, starting last, clears the screen by this time.
|
||||
val end = pieces.maxOf { it.delay + 1.1f / it.speed }
|
||||
while (t < end) {
|
||||
withFrameNanos { t = (it - start) / 1e9f }
|
||||
}
|
||||
onDone()
|
||||
}
|
||||
Canvas(modifier.fillMaxSize()) {
|
||||
val density = density
|
||||
for (p in pieces) {
|
||||
val age = t - p.delay
|
||||
if (age < 0f) continue
|
||||
val y = (age * p.speed - 0.05f) * size.height
|
||||
if (y > size.height + 40f) continue
|
||||
val x = (p.x + sin(age * p.swaySpeed) * p.sway) * size.width
|
||||
rotate(age * p.spin, pivot = Offset(x, y)) {
|
||||
drawRect(
|
||||
p.color,
|
||||
topLeft = Offset(x - p.width * density / 2, y - p.height * density / 2),
|
||||
size = Size(p.width * density, p.height * density),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val PIECES = 140
|
||||
@@ -77,6 +77,10 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
|
||||
// The guide asks for this now, but anyone who finished it before that never saw the page.
|
||||
val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||
// Reads the certificate once in the background, so Settings has it ready when opened.
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { runCatching { CertSummary.read(context) } }
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if (!needsSetupGuide &&
|
||||
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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
|
||||
@@ -31,6 +32,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
@@ -41,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.SessionStatus
|
||||
import app.seb3thehacker.gearslip.notify.TestMessage
|
||||
import app.seb3thehacker.gearslip.stats.WorkedPrompt
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
@@ -52,81 +55,96 @@ fun HomeScreen(
|
||||
onDisconnect: () -> Unit,
|
||||
) {
|
||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val workedCar by remember { WorkedPrompt.pending(context) }.collectAsStateWithLifecycle()
|
||||
val live = status.phase == SessionStatus.Phase.CONNECTING ||
|
||||
status.phase == SessionStatus.Phase.PROJECTING
|
||||
|
||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp),
|
||||
) {
|
||||
val celebrate by WorkedPrompt.celebrate.collectAsStateWithLifecycle()
|
||||
Box {
|
||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp),
|
||||
) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
"Gearslip",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
IconButton(
|
||||
onClick = onOpenSettings,
|
||||
modifier = Modifier.size(56.dp),
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp))
|
||||
Text(
|
||||
"Gearslip",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
IconButton(
|
||||
onClick = onOpenSettings,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"v${LocalContext.current.appVersionName()}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
StatusCard(status)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
UpdateCard()
|
||||
}
|
||||
|
||||
// Sits at the bottom, where a thumb reaches it.
|
||||
Column(Modifier.padding(vertical = 16.dp)) {
|
||||
val live = status.phase == SessionStatus.Phase.CONNECTING ||
|
||||
status.phase == SessionStatus.Phase.PROJECTING
|
||||
if (live) {
|
||||
Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text("Disconnect")
|
||||
Text(
|
||||
"v${LocalContext.current.appVersionName()}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
StatusCard(status)
|
||||
// The first drive that worked asks once to share it, right under the result.
|
||||
val car = workedCar
|
||||
if (car != null && !live) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
WorkedCard(car, onSeeWhatsSent = onOpenUsageNotes)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
UpdateCard()
|
||||
}
|
||||
|
||||
UsageNotesHomeCard(onOpen = onOpenUsageNotes)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Column {
|
||||
if (!live) {
|
||||
ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview)
|
||||
RowDivider()
|
||||
// Sits at the bottom, where a thumb reaches it.
|
||||
Column(Modifier.padding(vertical = 16.dp)) {
|
||||
if (live) {
|
||||
Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text("Disconnect")
|
||||
}
|
||||
ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
|
||||
RowDivider()
|
||||
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
|
||||
if (BuildConfig.DEBUG) {
|
||||
val context = LocalContext.current
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Not while the card above is asking the same thing.
|
||||
if (workedCar == null || live) {
|
||||
UsageNotesHomeCard(onOpen = onOpenUsageNotes)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Column {
|
||||
if (!live) {
|
||||
ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview)
|
||||
RowDivider()
|
||||
}
|
||||
ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
|
||||
RowDivider()
|
||||
ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) }
|
||||
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
|
||||
if (BuildConfig.DEBUG) {
|
||||
RowDivider()
|
||||
ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sharing just went on from the "it worked" ask: a little thanks.
|
||||
if (celebrate) ConfettiFall(onDone = WorkedPrompt::celebrated)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +181,8 @@ private fun CertificateSection() {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var refresh by remember { mutableIntStateOf(0) }
|
||||
val summary by produceState<CertSummary?>(null, refresh) {
|
||||
// The cached summary shows at once; the read still runs, in case the cert changed since.
|
||||
val summary by produceState(CertSummary.cached, refresh) {
|
||||
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ import android.content.Context
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.TextButton
|
||||
import app.seb3thehacker.gearslip.stats.WorkedPrompt
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -173,6 +180,54 @@ private fun buildNote(): String? = when {
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-time ask after the first drive that worked, in the status card's place on Home. Share
|
||||
* turns on usage notes like the switch does; the link shows exactly what that sends first.
|
||||
*/
|
||||
@Composable
|
||||
internal fun WorkedCard(car: String, onSeeWhatsSent: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Surface(
|
||||
modifier,
|
||||
color = scheme.primaryContainer,
|
||||
contentColor = scheme.onPrimaryContainer,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Check, contentDescription = null)
|
||||
Text(
|
||||
WorkedPrompt.TITLE_START + car,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Share that so the next driver knows it works? No name, account, or location.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
buildNote()?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(onClick = onSeeWhatsSent, contentPadding = PaddingValues(horizontal = 0.dp)) {
|
||||
Text("What gets sent")
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
OutlinedButton(onClick = { WorkedPrompt.answer(context, share = false) }) { Text("Not now") }
|
||||
Button(onClick = { WorkedPrompt.answer(context, share = true) }) { Text("Share") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage notes on Home, one row with one action either way. While they're off, the row is the
|
||||
* switch, so opting in is a single tap. Once they're on, it shrinks to a quiet line that opens
|
||||
|
||||
Reference in new issue
Block a user