mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
setup: the 0.3 look, with a badge, a big headline and shorter words on every step
This commit is contained in:
1 parent
b3f2088a1d
commit
0d0bfd3d66
2 files changed
+234
-149
No files matched your search
@@ -1,6 +1,38 @@
|
|||||||
package app.seb3thehacker.gearslip.ui
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Build
|
||||||
|
import androidx.compose.material.icons.filled.Call
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
|
import androidx.compose.material.icons.filled.Email
|
||||||
|
import androidx.compose.material.icons.filled.LocationOn
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material.icons.filled.Phone
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -27,15 +59,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@@ -66,14 +90,23 @@ private class GuidePage(
|
|||||||
val title: String,
|
val title: String,
|
||||||
/** Plain text, or an [AnnotatedString] when part of it needs to stand out. */
|
/** Plain text, or an [AnnotatedString] when part of it needs to stand out. */
|
||||||
val explanation: CharSequence,
|
val explanation: CharSequence,
|
||||||
|
/** Drawn inside the page's badge, in the badge's content colour. */
|
||||||
|
val icon: @Composable (Color) -> Unit,
|
||||||
|
/** Marked "Optional" above the title: everything works without it. */
|
||||||
|
val optional: Boolean = false,
|
||||||
val permissions: Array<String>? = null,
|
val permissions: Array<String>? = null,
|
||||||
val command: String? = null,
|
val command: String? = null,
|
||||||
val actionLabel: String? = null,
|
val actionLabel: String? = null,
|
||||||
val onAction: (() -> Unit)? = null,
|
val onAction: (() -> Unit)? = null,
|
||||||
/** Drawn under [explanation], inside the card. */
|
/** Drawn under [explanation]. */
|
||||||
val content: (@Composable () -> Unit)? = null,
|
val content: (@Composable () -> Unit)? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** A plain Material icon, sized to fill the badge's icon box. */
|
||||||
|
private fun glyph(icon: ImageVector): @Composable (Color) -> Unit = { tint ->
|
||||||
|
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
|
||||||
/** True once every permission in [permissions] is already granted - nothing left for this page to do. */
|
/** True once every permission in [permissions] is already granted - nothing left for this page to do. */
|
||||||
private fun granted(context: Context, permissions: Array<String>): Boolean =
|
private fun granted(context: Context, permissions: Array<String>): Boolean =
|
||||||
permissions.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
permissions.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
||||||
@@ -105,9 +138,11 @@ private fun hasNotificationAccess(context: Context): Boolean {
|
|||||||
* who set up before the notes existed see once. Leaving it without turning them on is a no.
|
* who set up before the notes existed see once. Leaving it without turning them on is a no.
|
||||||
*/
|
*/
|
||||||
private fun usageNotesPage(context: Context) = GuidePage(
|
private fun usageNotesPage(context: Context) = GuidePage(
|
||||||
title = USAGE_STATS_TITLE,
|
title = "Help other drivers.",
|
||||||
explanation = USAGE_STATS_INTRO,
|
explanation = USAGE_STATS_SHORT,
|
||||||
|
icon = { HeartIcon(it) },
|
||||||
content = {
|
content = {
|
||||||
|
UsageStatsReadMore()
|
||||||
UsageStatsChoices()
|
UsageStatsChoices()
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose { if (!UsageStats.asked(context)) UsageStats.set(context, false) }
|
onDispose { if (!UsageStats.asked(context)) UsageStats.set(context, false) }
|
||||||
@@ -119,11 +154,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
// --- Safety: always first, and never skipped by an already-granted check ---------
|
// --- Safety: always first, and never skipped by an already-granted check ---------
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Drive safely",
|
title = "Drive safely.",
|
||||||
explanation = "This guide sets up the screen you will see mounted in your car. " +
|
explanation = "Gearslip is unofficial. No carmaker made or checked it, and it can fail " +
|
||||||
"Gearslip is unofficial software: it is not made or reviewed by any carmaker, " +
|
"without warning. Set it up before you go, then keep your eyes on the road.",
|
||||||
"and it can fail without warning. Never interact with it while driving - set it " +
|
icon = glyph(Icons.Filled.Warning),
|
||||||
"up, mount the phone, then keep your attention on the road.",
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -132,10 +166,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
if (!granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
|
if (!granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Microphone",
|
title = "Microphone.",
|
||||||
explanation = "Gearslip needs the microphone for two jobs: passing your music " +
|
explanation = "Gearslip uses it to send your music to the car and to hear you when you " +
|
||||||
"app's sound to the car, and hearing you when you reply to a message or ask " +
|
"ask for something or reply to a message. What you say stays on the phone.",
|
||||||
"the assistant. It listens only then, and your speech stays on the phone.",
|
icon = { MicIcon(it) },
|
||||||
permissions = arrayOf(Manifest.permission.RECORD_AUDIO),
|
permissions = arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -143,9 +177,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
if (!granted(context, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))) {
|
if (!granted(context, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))) {
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Location",
|
title = "Location.",
|
||||||
explanation = "The car's map and nearby places need to know where you are. " +
|
explanation = "The car's map and nearby searches need it. Without it, the dashboard " +
|
||||||
"Without it, the dashboard has no map card.",
|
"shows no map.",
|
||||||
|
icon = glyph(Icons.Filled.LocationOn),
|
||||||
permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
|
permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -153,9 +188,9 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
if (!granted(context, arrayOf(Manifest.permission.READ_CALENDAR))) {
|
if (!granted(context, arrayOf(Manifest.permission.READ_CALENDAR))) {
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Calendar",
|
title = "Calendar.",
|
||||||
explanation = "Gearslip reads your next event to show an agenda card on the car " +
|
explanation = "The car screen shows your next event. Gearslip only reads your calendar.",
|
||||||
"screen. It only reads; it never edits or deletes anything.",
|
icon = glyph(Icons.Filled.DateRange),
|
||||||
permissions = arrayOf(Manifest.permission.READ_CALENDAR),
|
permissions = arrayOf(Manifest.permission.READ_CALENDAR),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -170,11 +205,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
if (!granted(context, callPermissions)) {
|
if (!granted(context, callPermissions)) {
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Phone and contacts",
|
title = "Calls and contacts.",
|
||||||
explanation = "When a call comes in, Gearslip matches the number against your " +
|
explanation = "The car shows who's calling by name, and its Phone app can dial a " +
|
||||||
"contacts, so the car screen can show a name instead of just digits. The " +
|
"number or call one of your contacts. Gearslip only reads them.",
|
||||||
"same permissions let the car screen's Phone app dial a number or call " +
|
icon = glyph(Icons.Filled.Phone),
|
||||||
"someone from your contacts.",
|
|
||||||
permissions = callPermissions,
|
permissions = callPermissions,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -183,10 +217,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
if (!granted(context, arrayOf(Manifest.permission.POST_NOTIFICATIONS))) {
|
if (!granted(context, arrayOf(Manifest.permission.POST_NOTIFICATIONS))) {
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Notifications",
|
title = "Notifications.",
|
||||||
explanation = "Gearslip shows a notification while it's connected to the car or " +
|
explanation = "While Gearslip is connected, it shows a notification with a button " +
|
||||||
"sending it audio, with a button to stop. Without this permission, Android " +
|
"to stop. Without this, Android hides it.",
|
||||||
"hides it.",
|
icon = glyph(Icons.Filled.Notifications),
|
||||||
permissions = arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
permissions = arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -194,10 +228,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
if (!hasNotificationAccess(context)) {
|
if (!hasNotificationAccess(context)) {
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Notification access",
|
title = "Messages.",
|
||||||
explanation = "The car screen shows your phone's notifications and lets you " +
|
explanation = "The car shows your messages, reads them aloud and lets you reply. " +
|
||||||
"reply from there. Android only allows that once you grant Gearslip access, " +
|
"Turn Gearslip on in the list this opens.",
|
||||||
"from the list this opens - it needs this to work at all.",
|
icon = glyph(Icons.Filled.Email),
|
||||||
actionLabel = "Open notification access",
|
actionLabel = "Open notification access",
|
||||||
onAction = {
|
onAction = {
|
||||||
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||||
@@ -212,10 +246,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
if (androidAutoPackage != null) {
|
if (androidAutoPackage != null) {
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Remove Android Auto",
|
title = "Turn off Android Auto.",
|
||||||
explanation = "Android Auto is still installed. It claims the same USB " +
|
explanation = "Android Auto is still installed, and it fights Gearslip for the car's " +
|
||||||
"connection Gearslip needs, so the two fight over it. Disable or uninstall " +
|
"USB connection. Disable or uninstall it on the screen this opens.",
|
||||||
"it from the app info screen this opens.",
|
icon = glyph(Icons.Filled.Close),
|
||||||
actionLabel = "Open Android Auto's app info",
|
actionLabel = "Open Android Auto's app info",
|
||||||
onAction = {
|
onAction = {
|
||||||
context.startActivity(
|
context.startActivity(
|
||||||
@@ -229,11 +263,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
// --- Default handler ---------------------------------------------------------------
|
// --- Default handler ---------------------------------------------------------------
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Make Gearslip the default",
|
title = "Choose Gearslip.",
|
||||||
explanation = "Android has no separate default-app setting for a USB accessory like " +
|
explanation = "If your phone asks which app to open when you plug into the car, pick " +
|
||||||
"the car. It just launches whichever single app matches - which is Gearslip, " +
|
"Gearslip and tap Always.",
|
||||||
"now that Android Auto is out of the way. If a chooser still appears when you " +
|
icon = glyph(Icons.Filled.Check),
|
||||||
"plug in, pick Gearslip and tap Always so Android stops asking.",
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -241,10 +274,11 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
if (!hasProjectMediaOp(context)) {
|
if (!hasProjectMediaOp(context)) {
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Optional: trust audio every drive",
|
title = "No audio prompt.",
|
||||||
explanation = "Connect the phone to a computer with USB debugging on, and run this " +
|
explanation = "Run this from a computer with USB debugging on, and Android trusts " +
|
||||||
"command. It tells Android to trust Gearslip with audio from now on. Skip it " +
|
"Gearslip with your audio for good. Without it, you tap Start once each drive.",
|
||||||
"and everything still works, but you tap Start once each time you set off.",
|
icon = glyph(Icons.Filled.Build),
|
||||||
|
optional = true,
|
||||||
command = "adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow",
|
command = "adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -252,10 +286,12 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
if (!granted(context, arrayOf(Manifest.permission.WRITE_SECURE_SETTINGS))) {
|
if (!granted(context, arrayOf(Manifest.permission.WRITE_SECURE_SETTINGS))) {
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Optional: keep notifications readable",
|
title = "Readable notifications.",
|
||||||
explanation = "Android treats sending audio to the car like sharing your screen, and " +
|
explanation = "While audio goes to the car, Android hides what notifications say, " +
|
||||||
"hides what your notifications say. This command switches that off only while " +
|
"as if you were sharing your screen. This lets Gearslip show them during a " +
|
||||||
"music is going to the car, then puts it back.",
|
"drive and hide them again after.",
|
||||||
|
icon = glyph(Icons.Filled.Build),
|
||||||
|
optional = true,
|
||||||
command = "adb shell pm grant ${BuildConfig.APPLICATION_ID} android.permission.WRITE_SECURE_SETTINGS",
|
command = "adb shell pm grant ${BuildConfig.APPLICATION_ID} android.permission.WRITE_SECURE_SETTINGS",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -263,10 +299,11 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
if (!hasCallScreeningRole(context)) {
|
if (!hasCallScreeningRole(context)) {
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Optional: answer and decline calls",
|
title = "Answer calls.",
|
||||||
explanation = "This sets Gearslip as your phone's caller ID and spam-blocking app, " +
|
explanation = "Make Gearslip your caller ID app, and the car can answer and decline " +
|
||||||
"the same kind of consent a call-blocking app asks for. Without it, calls still " +
|
"calls. Without it, calls still ring, but only the phone can answer them.",
|
||||||
"ring, but only the phone itself can answer or decline them.",
|
icon = glyph(Icons.Filled.Call),
|
||||||
|
optional = true,
|
||||||
actionLabel = "Set up",
|
actionLabel = "Set up",
|
||||||
onAction = onRequestCallScreening,
|
onAction = onRequestCallScreening,
|
||||||
),
|
),
|
||||||
@@ -277,8 +314,9 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
if (SHOW_RECOMMENDED_APPS) {
|
if (SHOW_RECOMMENDED_APPS) {
|
||||||
add(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Recommended apps",
|
title = "Good company.",
|
||||||
explanation = RECOMMENDED_INTRO,
|
explanation = RECOMMENDED_INTRO,
|
||||||
|
icon = { StarIcon(it) },
|
||||||
content = { RecommendedAppsList() },
|
content = { RecommendedAppsList() },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -325,31 +363,23 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
|||||||
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||||
} ?: false
|
} ?: false
|
||||||
|
|
||||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
val c = introColors()
|
||||||
Column(
|
// The badges take turns through the phone's three accent colours, as the release intro does.
|
||||||
Modifier
|
val (badge, onBadge) = listOf(c.primaryC to c.onPrimaryC, c.secondaryC to c.onSecondaryC, c.tertiaryC to c.onTertiaryC)[index % 3]
|
||||||
.fillMaxSize()
|
val spin by rememberInfiniteTransition(label = "spin").animateFloat(
|
||||||
.padding(padding)
|
0f, 360f, infiniteRepeatable(tween(18_000, easing = LinearEasing)), label = "spin",
|
||||||
.padding(horizontal = 24.dp),
|
)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
BoxWithConstraints(Modifier.fillMaxSize().background(c.surface).systemBarsPadding()) {
|
||||||
Spacer(Modifier.height(24.dp))
|
val w = maxWidth
|
||||||
// A single page has no progress to show.
|
// One percent of the screen's width, as in the intro, so it scales the same way.
|
||||||
if (pages.size > 1) {
|
fun cq(n: Float): Dp = w * (n / 100f)
|
||||||
LinearProgressIndicator(
|
val density = LocalDensity.current
|
||||||
progress = { (index + 1f) / pages.size },
|
fun cqSp(n: Float): TextUnit = with(density) { cq(n).toSp() }
|
||||||
modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge),
|
|
||||||
)
|
Column(Modifier.fillMaxSize().padding(horizontal = cq(8f))) {
|
||||||
Spacer(Modifier.height(12.dp))
|
// The page scrolls above the buttons, so a tall one (the usage notes' choices) never
|
||||||
Text(
|
// pushes them off the screen. Short pages sit in the middle.
|
||||||
"${index + 1} of ${pages.size}",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// The page scrolls between the progress bar and the buttons, so a tall page (the usage
|
|
||||||
// note's choices, say) never pushes Next off the screen. Short pages stay centred.
|
|
||||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
key(index) {
|
key(index) {
|
||||||
Column(
|
Column(
|
||||||
@@ -357,88 +387,114 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.heightIn(min = maxHeight)
|
.heightIn(min = maxHeight)
|
||||||
.padding(vertical = 24.dp),
|
.padding(vertical = cq(6f)),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) {
|
Box(Modifier.size(cq(28f)).pop(entrance(index, 50, 800)), contentAlignment = Alignment.Center) {
|
||||||
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Cookie(badge, Modifier.fillMaxSize().graphicsLayer { rotationZ = spin })
|
||||||
|
Box(Modifier.size(cq(12f))) { page.icon(onBadge) }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(cq(7f)))
|
||||||
|
if (page.optional) {
|
||||||
|
Text(
|
||||||
|
"OPTIONAL", color = c.primary,
|
||||||
|
style = TextStyle(fontSize = cqSp(3.4f), fontWeight = FontWeight.Bold, letterSpacing = cqSp(0.4f)),
|
||||||
|
modifier = Modifier.up(entrance(index, 150, 700), cq(7f)),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(cq(1.5f)))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
page.title, color = c.onSurface,
|
||||||
|
// Long titles step down a size so they stay on two lines.
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = cqSp(if (page.title.length > 16) 8f else 10f), fontWeight = FontWeight.Black,
|
||||||
|
letterSpacing = cqSp(-0.4f), lineHeight = cqSp(if (page.title.length > 16) 8.6f else 10.5f),
|
||||||
|
),
|
||||||
|
modifier = Modifier.up(entrance(index, 200, 700), cq(7f)),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(cq(4f)))
|
||||||
|
Column(
|
||||||
|
Modifier.up(entrance(index, 350, 700), cq(7f)),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(cq(3f)),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
page.explanation as? AnnotatedString ?: AnnotatedString(page.explanation.toString()),
|
||||||
|
color = c.onVariant,
|
||||||
|
style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)),
|
||||||
|
)
|
||||||
|
page.command?.let { CopyableCommand(it) }
|
||||||
|
page.content?.invoke()
|
||||||
|
if (page.permissions != null && alreadyGranted) {
|
||||||
Text(
|
Text(
|
||||||
page.title,
|
"Already allowed.", color = c.primary,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.SemiBold),
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
page.explanation as? AnnotatedString ?: AnnotatedString(page.explanation.toString()),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
page.command?.let { CopyableCommand(it) }
|
|
||||||
page.content?.invoke()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A single page has nothing to count.
|
||||||
|
if (pages.size > 1) {
|
||||||
|
Dots(c, pages.size, index, ::cq, Modifier.align(Alignment.CenterHorizontally))
|
||||||
|
Spacer(Modifier.height(cq(5f)))
|
||||||
|
}
|
||||||
|
val back = if (index > 0) ({ index -= 1 }) else null
|
||||||
|
val next = { index += 1 }
|
||||||
when {
|
when {
|
||||||
page.permissions != null && alreadyGranted -> {
|
page.permissions != null && !alreadyGranted -> {
|
||||||
Text(
|
PillRow(c, ::cq, ::cqSp, back, "Allow") { launcher.launch(page.permissions) }
|
||||||
"Already allowed.",
|
Pill(c, ::cqSp, "Skip for now", PillStyle.TEXT, Modifier.align(Alignment.CenterHorizontally), next)
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
|
||||||
Button(onClick = { index += 1 }, modifier = it) { Text("Next") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
page.permissions != null -> {
|
|
||||||
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
|
||||||
Button(onClick = { launcher.launch(page.permissions) }, modifier = it) { Text("Allow") }
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
TextButton(onClick = { index += 1 }) { Text("Skip for now") }
|
|
||||||
}
|
}
|
||||||
page.onAction != null -> {
|
page.onAction != null -> {
|
||||||
FilledTonalButton(
|
Pill(c, ::cqSp, page.actionLabel ?: "Open", PillStyle.OUTLINED, Modifier.fillMaxWidth().padding(bottom = cq(3f)), page.onAction)
|
||||||
onClick = page.onAction,
|
PillRow(c, ::cq, ::cqSp, back, "Next", next)
|
||||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
|
||||||
) { Text(page.actionLabel ?: "Open") }
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
|
||||||
Button(onClick = { index += 1 }, modifier = it) { Text("Next") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
|
||||||
Button(onClick = { index += 1 }, modifier = it) {
|
|
||||||
Text(if (index == pages.lastIndex) "Done" else "Next")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
else -> PillRow(c, ::cq, ::cqSp, back, if (index == pages.lastIndex) "Done" else "Next", next)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(cq(6f)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private enum class PillStyle { FILLED, OUTLINED, TEXT }
|
||||||
* The bottom action row: a lower-emphasis Back on the left once there is a previous page, and the
|
|
||||||
* page's own primary button - passed [content] so the caller keeps the button's own label and
|
/** The intro's rounded buttons: filled for the step's main action, outlined beside it, bare text to skip. */
|
||||||
* click behavior - filling the rest of the width.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PrimaryRow(showBack: Boolean, onBack: () -> Unit, content: @Composable (Modifier) -> Unit) {
|
private fun Pill(c: IntroColors, cqSp: (Float) -> TextUnit, label: String, style: PillStyle, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
if (showBack) {
|
val shape = RoundedCornerShape(50)
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Box(
|
||||||
OutlinedButton(
|
modifier
|
||||||
onClick = onBack,
|
.clip(shape)
|
||||||
modifier = Modifier.weight(1f).height(52.dp),
|
.then(
|
||||||
) { Text("Back") }
|
when (style) {
|
||||||
content(Modifier.weight(1f).height(52.dp))
|
PillStyle.FILLED -> Modifier.background(c.primary)
|
||||||
}
|
PillStyle.OUTLINED -> Modifier.border(BorderStroke(2.dp, c.outline), shape)
|
||||||
} else {
|
PillStyle.TEXT -> Modifier
|
||||||
content(Modifier.fillMaxWidth().height(52.dp))
|
},
|
||||||
|
)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.heightIn(min = 52.dp)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = if (style == PillStyle.FILLED) c.onPrimary else c.primary,
|
||||||
|
style = TextStyle(fontSize = cqSp(4.4f), fontWeight = FontWeight.Bold),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back on the left once there's a page to go back to, and the page's main action filling the rest. */
|
||||||
|
@Composable
|
||||||
|
private fun PillRow(
|
||||||
|
c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit,
|
||||||
|
onBack: (() -> Unit)?, label: String, onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(cq(3f))) {
|
||||||
|
if (onBack != null) Pill(c, cqSp, "Back", PillStyle.OUTLINED, Modifier.weight(1f), onBack)
|
||||||
|
Pill(c, cqSp, label, PillStyle.FILLED, Modifier.weight(if (onBack != null) 1.4f else 1f), onClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,10 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
@@ -70,6 +74,31 @@ internal val USAGE_STATS_INTRO: AnnotatedString = buildAnnotatedString {
|
|||||||
append(INTRO_END)
|
append(INTRO_END)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The setup guide's version of [USAGE_STATS_INTRO]: what the notes are and why, in three
|
||||||
|
* sentences. The full text is one tap away in [UsageStatsReadMore].
|
||||||
|
*/
|
||||||
|
internal val USAGE_STATS_SHORT: AnnotatedString = buildAnnotatedString {
|
||||||
|
append("Gearslip can send a short note after each drive: its version and the car details you pick below. ")
|
||||||
|
append("The notes show which cars work and where the others fail. ")
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("No name, account, or location goes with them.") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Read more", opening the whole pitch and privacy promise in a dialog. */
|
||||||
|
@Composable
|
||||||
|
internal fun UsageStatsReadMore() {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
TextButton(onClick = { open = true }, contentPadding = PaddingValues(0.dp)) { Text("Read more") }
|
||||||
|
if (open) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { open = false },
|
||||||
|
title = { Text("About usage notes") },
|
||||||
|
text = { Text(USAGE_STATS_INTRO, modifier = Modifier.verticalScroll(rememberScrollState())) },
|
||||||
|
confirmButton = { TextButton(onClick = { open = false }) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The usage notes card for the setup guide: the same rows Settings shows. */
|
/** The usage notes card for the setup guide: the same rows Settings shows. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun UsageStatsChoices(modifier: Modifier = Modifier) {
|
internal fun UsageStatsChoices(modifier: Modifier = Modifier) {
|
||||||
|
|||||||
Reference in new issue
Block a user