mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 08:12:26 +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
+370
-5
No files matched your search
@@ -160,6 +160,11 @@
|
|||||||
android:name=".notify.TestMessageReceiver"
|
android:name=".notify.TestMessageReceiver"
|
||||||
android:exported="false" />
|
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
|
<service
|
||||||
android:name=".mirror.MirrorService"
|
android:name=".mirror.MirrorService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ object SessionReport {
|
|||||||
runCatching {
|
runCatching {
|
||||||
UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi, sessionNote())
|
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) }
|
runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) }
|
||||||
}
|
}
|
||||||
GearslipLog.flush()
|
GearslipLog.flush()
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ object UsageStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun set(context: Context, enabled: Boolean, choices: Choices = choices(context)) {
|
fun set(context: Context, enabled: Boolean, choices: Choices = choices(context)) {
|
||||||
|
val wasEnabled = enabled(context)
|
||||||
prefs(context).edit()
|
prefs(context).edit()
|
||||||
.putBoolean(KEY_ENABLED, enabled)
|
.putBoolean(KEY_ENABLED, enabled)
|
||||||
.putBoolean(KEY_ASKED, true)
|
.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)
|
if (!enabled) prefs(context).edit().remove(KEY_CARS).remove(KEY_LAST_CARS).remove(KEY_SENT_DAY)
|
||||||
.remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply()
|
.remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply()
|
||||||
if (!choices.failures) prefs(context).edit().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. */
|
/** 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?,
|
val validUntil: String?,
|
||||||
) {
|
) {
|
||||||
companion object {
|
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. */
|
/** 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)
|
val identity = CertProvider.loadSupplied(context)
|
||||||
?: return CertSummary(
|
?: return CertSummary(
|
||||||
CertProvider.Kind.SELF_SIGNED,
|
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.
|
// The guide asks for this now, but anyone who finished it before that never saw the page.
|
||||||
val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
if (!needsSetupGuide &&
|
if (!needsSetupGuide &&
|
||||||
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -31,6 +32,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
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.BuildConfig
|
||||||
import app.seb3thehacker.gearslip.SessionStatus
|
import app.seb3thehacker.gearslip.SessionStatus
|
||||||
import app.seb3thehacker.gearslip.notify.TestMessage
|
import app.seb3thehacker.gearslip.notify.TestMessage
|
||||||
|
import app.seb3thehacker.gearslip.stats.WorkedPrompt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
@@ -52,7 +55,13 @@ fun HomeScreen(
|
|||||||
onDisconnect: () -> Unit,
|
onDisconnect: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
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
|
||||||
|
|
||||||
|
val celebrate by WorkedPrompt.celebrate.collectAsStateWithLifecycle()
|
||||||
|
Box {
|
||||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -91,14 +100,18 @@ fun HomeScreen(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
StatusCard(status)
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
UpdateCard()
|
UpdateCard()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sits at the bottom, where a thumb reaches it.
|
// Sits at the bottom, where a thumb reaches it.
|
||||||
Column(Modifier.padding(vertical = 16.dp)) {
|
Column(Modifier.padding(vertical = 16.dp)) {
|
||||||
val live = status.phase == SessionStatus.Phase.CONNECTING ||
|
|
||||||
status.phase == SessionStatus.Phase.PROJECTING
|
|
||||||
if (live) {
|
if (live) {
|
||||||
Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||||
Text("Disconnect")
|
Text("Disconnect")
|
||||||
@@ -106,8 +119,11 @@ fun HomeScreen(
|
|||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not while the card above is asking the same thing.
|
||||||
|
if (workedCar == null || live) {
|
||||||
UsageNotesHomeCard(onOpen = onOpenUsageNotes)
|
UsageNotesHomeCard(onOpen = onOpenUsageNotes)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
|
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
|
||||||
Column {
|
Column {
|
||||||
@@ -119,7 +135,6 @@ fun HomeScreen(
|
|||||||
RowDivider()
|
RowDivider()
|
||||||
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
|
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
val context = LocalContext.current
|
|
||||||
RowDivider()
|
RowDivider()
|
||||||
ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) }
|
ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) }
|
||||||
}
|
}
|
||||||
@@ -128,6 +143,9 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sharing just went on from the "it worked" ask: a little thanks.
|
||||||
|
if (celebrate) ConfettiFall(onDone = WorkedPrompt::celebrated)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -181,7 +181,8 @@ private fun CertificateSection() {
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var refresh by remember { mutableIntStateOf(0) }
|
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) }
|
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import android.content.Context
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
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.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -173,6 +180,54 @@ private fun buildNote(): String? = when {
|
|||||||
else -> null
|
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
|
* 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
|
* 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