mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
0.3 intro, donation links on Home, and About links for Matrix and the source
This commit is contained in:
1 parent
59657c4f80
commit
94ed58e13f
5 files changed
+731
-15
No files matched your search
@@ -37,7 +37,7 @@ import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.CertProvider
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
|
||||
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS }
|
||||
private enum class Screen { INTRO, HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS }
|
||||
|
||||
/** Three screens and a back stack of depth one: no navigation library needed. */
|
||||
@Composable
|
||||
@@ -47,20 +47,22 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
!AppSettings.hasSkippedCertSetup(context) && !CertProvider.hasAnyCert(context)
|
||||
}
|
||||
val needsSetupGuide = remember { !AppSettings.hasSeenPermissionsSetup(context) }
|
||||
var screen by rememberSaveable {
|
||||
mutableStateOf(
|
||||
when {
|
||||
needsCertSetup -> Screen.CERT_SETUP
|
||||
needsSetupGuide -> Screen.SETUP_GUIDE
|
||||
// Set up before usage notes existed: ask once, on the setup guide's own page.
|
||||
!UsageStats.asked(context) -> Screen.USAGE_NOTES
|
||||
else -> Screen.HOME
|
||||
},
|
||||
)
|
||||
// Where launch goes once the intro (if any) is done.
|
||||
val afterIntro = remember {
|
||||
when {
|
||||
needsCertSetup -> Screen.CERT_SETUP
|
||||
needsSetupGuide -> Screen.SETUP_GUIDE
|
||||
// Set up before usage notes existed: ask once, on the setup guide's own page.
|
||||
!UsageStats.asked(context) -> Screen.USAGE_NOTES
|
||||
else -> Screen.HOME
|
||||
}
|
||||
}
|
||||
var screen by rememberSaveable { mutableStateOf(if (Intro.due(context)) Screen.INTRO else afterIntro) }
|
||||
// A replay from About goes back to Settings; the launch one carries on into setup.
|
||||
var introReturn by rememberSaveable { mutableStateOf(afterIntro) }
|
||||
// Home's usage notes row opens Settings at that section rather than at the top.
|
||||
var settingsAtUsageNotes by rememberSaveable { mutableStateOf(false) }
|
||||
BackHandler(enabled = screen != Screen.HOME) {
|
||||
BackHandler(enabled = screen != Screen.HOME && screen != Screen.INTRO) {
|
||||
screen = if (screen == Screen.RECOMMENDED_APPS) Screen.SETTINGS else Screen.HOME
|
||||
}
|
||||
|
||||
@@ -93,10 +95,11 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.consumeWindowInsets(WindowInsets.statusBars),
|
||||
) {
|
||||
if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW) DevBuildBanner()
|
||||
if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW && screen != Screen.INTRO) DevBuildBanner()
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
when (screen) {
|
||||
Screen.INTRO -> IntroScreen(onDone = { screen = introReturn })
|
||||
Screen.HOME -> HomeScreen(
|
||||
onOpenLogs = { screen = Screen.LOGS },
|
||||
onOpenSettings = { settingsAtUsageNotes = false; screen = Screen.SETTINGS },
|
||||
@@ -112,6 +115,7 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
onOpenRecommendedApps = { settingsAtUsageNotes = false; screen = Screen.RECOMMENDED_APPS },
|
||||
onOpenWhatsNew = { settingsAtUsageNotes = false; screen = Screen.WHATS_NEW },
|
||||
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
|
||||
onReplayIntro = { introReturn = Screen.SETTINGS; screen = Screen.INTRO },
|
||||
atUsageNotes = settingsAtUsageNotes,
|
||||
)
|
||||
Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS })
|
||||
|
||||
@@ -108,6 +108,10 @@ fun HomeScreen(
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
UpdateCard()
|
||||
if (!live) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SupportCard()
|
||||
}
|
||||
}
|
||||
|
||||
// Sits at the bottom, where a thumb reaches it.
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
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.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* The release intro: a few tap-through screens shown once after each new release (and on a new
|
||||
* install), before setup. Its look follows the 0.3 mockup: Material shapes in the phone's own colours,
|
||||
* with every size taken from the screen's width so it scales from small phones to tablets.
|
||||
*/
|
||||
object Intro {
|
||||
private const val PREFS = "intro"
|
||||
private const val KEY_SEEN = "seen_release"
|
||||
|
||||
/** "0.3" from "0.3.00-alpha": the intro belongs to a release, not to each patch. */
|
||||
val release: String = BuildConfig.VERSION_NAME.split('.').take(2).joinToString(".")
|
||||
|
||||
fun due(context: Context): Boolean =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_SEEN, null) != release
|
||||
|
||||
fun markSeen(context: Context) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_SEEN, release).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/** The intro's tones, taken from the phone's own Material You colours like the rest of the app. */
|
||||
private class IntroColors(
|
||||
val surface: Color, val onSurface: Color, val onVariant: Color, val outline: Color,
|
||||
val primary: Color, val onPrimary: Color, val primaryC: Color, val onPrimaryC: Color,
|
||||
val tertiaryC: Color, val onTertiaryC: Color, val secondaryC: Color, val onSecondaryC: Color,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun introColors(): IntroColors {
|
||||
val m = MaterialTheme.colorScheme
|
||||
return IntroColors(
|
||||
surface = m.surface, onSurface = m.onSurface, onVariant = m.onSurfaceVariant, outline = m.outline,
|
||||
primary = m.primary, onPrimary = m.onPrimary, primaryC = m.primaryContainer, onPrimaryC = m.onPrimaryContainer,
|
||||
tertiaryC = m.tertiaryContainer, onTertiaryC = m.onTertiaryContainer,
|
||||
secondaryC = m.secondaryContainer, onSecondaryC = m.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
|
||||
private enum class IntroStep { WELCOME, WIRELESS, KNOBS, GRAPHENE, THANKS, DRIVE }
|
||||
|
||||
private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f)
|
||||
private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f)
|
||||
|
||||
@Composable
|
||||
fun IntroScreen(onDone: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val c = introColors()
|
||||
// The GrapheneOS screen is only news to people running it.
|
||||
val steps = remember {
|
||||
IntroStep.entries.filter { it != IntroStep.GRAPHENE || UsageStats.osName(context) == "GrapheneOS" }
|
||||
}
|
||||
var index by remember { mutableIntStateOf(0) }
|
||||
val step = steps[index]
|
||||
val finish = {
|
||||
Intro.markSeen(context)
|
||||
onDone()
|
||||
}
|
||||
BackHandler(enabled = index > 0) { index-- }
|
||||
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(c.surface)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {
|
||||
if (index < steps.lastIndex) index++
|
||||
},
|
||||
) {
|
||||
val w = maxWidth
|
||||
// The mockup's cqw: one percent of the screen's width.
|
||||
fun cq(n: Float): Dp = w * (n / 100f)
|
||||
val density = LocalDensity.current
|
||||
fun cqSp(n: Float): TextUnit = with(density) { cq(n).toSp() }
|
||||
|
||||
when (step) {
|
||||
IntroStep.WELCOME -> Welcome(c, ::cq, ::cqSp, maxHeight)
|
||||
IntroStep.WIRELESS -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Wireless.",
|
||||
"If your car does wireless Android Auto, pair once and leave the cable at home.", step) { WifiIcon(c.onSecondaryC) }
|
||||
IntroStep.KNOBS -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Knobs work.",
|
||||
"Turn, push, done. No touchscreen? Gearslip doesn't need one anymore.", step) { DialIcon(c.onTertiaryC) }
|
||||
IntroStep.GRAPHENE -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Protections back on.",
|
||||
"On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode. If you " +
|
||||
"turned it on for Gearslip, turn it off to get full protection back.", step,
|
||||
action = "Open app settings" to {
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||
)
|
||||
}
|
||||
},
|
||||
outlined = true,
|
||||
) { ShieldIcon(c.onSecondaryC, c.secondaryC) }
|
||||
IntroStep.THANKS -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Thank you.",
|
||||
"To everyone sending usage notes: they show me which cars Gearslip works in and which " +
|
||||
"ones still fail, so I know what to fix next. Every note helps.", step) { HeartIcon(c.onTertiaryC) }
|
||||
IntroStep.DRIVE -> BadgeStep(c, ::cq, ::cqSp, c.primaryC, "Let's drive.",
|
||||
"That's ${Intro.release}. Plug in, or just get in.", step,
|
||||
action = "Start" to finish) { CheckIcon(c.onPrimaryC) }
|
||||
}
|
||||
|
||||
Dots(c, steps.size, index, ::cq, Modifier.align(Alignment.BottomCenter).padding(bottom = maxHeight * 0.05f))
|
||||
if (step != IntroStep.DRIVE) Hint(c, ::cqSp, step, Modifier.align(Alignment.BottomCenter).padding(bottom = maxHeight * 0.09f))
|
||||
}
|
||||
}
|
||||
|
||||
/** 0 to 1 once each time [key] changes, after [delayMs]: drives the entrance of one element. */
|
||||
@Composable
|
||||
private fun entrance(key: Any, delayMs: Int, durationMs: Int = 650): Float {
|
||||
val a = remember(key) { Animatable(0f) }
|
||||
LaunchedEffect(key) {
|
||||
delay(delayMs.toLong())
|
||||
a.animateTo(1f, tween(durationMs, easing = LinearEasing))
|
||||
}
|
||||
return a.value
|
||||
}
|
||||
|
||||
/** Slides up and fades in, like the mockup's `up`. */
|
||||
private fun Modifier.up(p: Float, distance: Dp) = graphicsLayer {
|
||||
val e = Rise.transform(p)
|
||||
alpha = e
|
||||
translationY = (1 - e) * distance.toPx()
|
||||
}
|
||||
|
||||
/** Grows in with a bounce, like the mockup's `bounce`. */
|
||||
private fun Modifier.pop(p: Float) = graphicsLayer {
|
||||
val e = Pop.transform(p)
|
||||
alpha = p.coerceAtMost(1f)
|
||||
scaleX = 0.5f + 0.5f * e
|
||||
scaleY = 0.5f + 0.5f * e
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Welcome(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, height: Dp) {
|
||||
val key = IntroStep.WELCOME
|
||||
val spin by rememberInfiniteTransition(label = "spin").animateFloat(
|
||||
0f, 360f, infiniteRepeatable(tween(18_000, easing = LinearEasing)), label = "spin",
|
||||
)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Cookie(c.primaryC, Modifier.size(cq(120f)).offset(x = cq(28f), y = -cq(22f)).align(Alignment.TopEnd).graphicsLayer { rotationZ = spin; alpha = 0.9f })
|
||||
Ticker(c.secondaryC, c.onSecondaryC, "OPEN SOURCE ✦ NO ACCOUNT ✦ YOUR CAR, YOUR PHONE ✦ ", cq, cqSp, reverse = true, tilt = 3f,
|
||||
key = key, modifier = Modifier.align(Alignment.TopCenter).padding(top = height * 0.11f))
|
||||
|
||||
Column(Modifier.align(Alignment.CenterStart).padding(horizontal = cq(8f))) {
|
||||
Text("Welcome to", color = c.onVariant, style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.up(entrance(key, 100), cq(7f)))
|
||||
Text(
|
||||
Intro.release, color = c.primary,
|
||||
style = TextStyle(fontSize = cqSp(44f), fontWeight = FontWeight.Black, letterSpacing = cqSp(-2.4f), lineHeight = cqSp(38f)),
|
||||
modifier = Modifier.offset(x = -cq(1.5f)).pop(entrance(key, 250, 900)),
|
||||
)
|
||||
Text(
|
||||
"alpha · the big one", color = c.onTertiaryC,
|
||||
style = TextStyle(fontSize = cqSp(4f), fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.padding(top = cq(4f)).up(entrance(key, 700), cq(7f))
|
||||
.clip(RoundedCornerShape(50)).background(c.tertiaryC).padding(horizontal = cq(4.4f), vertical = cq(2.2f)),
|
||||
)
|
||||
}
|
||||
|
||||
Ticker(c.primary, c.onPrimary, "WIRELESS ✦ KNOB SUPPORT ✦ FASTER VIDEO ✦ AUTO UPDATES ✦ ", cq, cqSp, reverse = false, tilt = -4f,
|
||||
key = key, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = height * 0.15f))
|
||||
Confetti(c, key, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
/** A tilted band of words scrolling past, slid in from the side when the step opens. */
|
||||
@Composable
|
||||
private fun Ticker(
|
||||
bg: Color, fg: Color, words: String, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit,
|
||||
reverse: Boolean, tilt: Float, key: Any, modifier: Modifier,
|
||||
) {
|
||||
val enter = entrance(key, 400, 800)
|
||||
val scroll by rememberInfiniteTransition(label = "ticker").animateFloat(
|
||||
0f, 1f, infiniteRepeatable(tween(if (reverse) 20_000 else 16_000, easing = LinearEasing)), label = "ticker",
|
||||
)
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
rotationZ = tilt
|
||||
scaleX = 1.2f
|
||||
val e = Rise.transform(enter)
|
||||
alpha = e
|
||||
translationX = (1 - e) * -cq(30f).toPx()
|
||||
}
|
||||
.background(bg)
|
||||
.padding(vertical = cq(2.6f)),
|
||||
) {
|
||||
Text(
|
||||
words.repeat(4),
|
||||
color = fg, maxLines = 1, softWrap = false,
|
||||
style = TextStyle(fontSize = cqSp(5f), fontWeight = FontWeight.ExtraBold),
|
||||
modifier = Modifier
|
||||
.wrapContentWidth(Alignment.Start, unbounded = true)
|
||||
.graphicsLayer {
|
||||
// The text holds the words four times; sliding by half loops cleanly.
|
||||
val shift = size.width / 2f * (if (reverse) 1 - scroll else scroll)
|
||||
translationX = -shift
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Material shapes thrown once from behind the version number. */
|
||||
@Composable
|
||||
private fun Confetti(c: IntroColors, key: Any, modifier: Modifier) {
|
||||
val p = entrance(key, 450, 1300)
|
||||
val bits = remember {
|
||||
val r = Random(7)
|
||||
List(22) { i ->
|
||||
val a = i / 22f * 2 * PI.toFloat() + r.nextFloat() * 0.4f
|
||||
val d = 30f + r.nextFloat() * 45f
|
||||
Bit(cos(a) * d, sin(a) * d * 1.2f, r.nextInt(-270, 270).toFloat(), i % 4, i % 3)
|
||||
}
|
||||
}
|
||||
if (p <= 0f || p >= 1f) return
|
||||
val tones = listOf(c.primary, c.primaryC, c.tertiaryC, c.secondaryC)
|
||||
Canvas(modifier) {
|
||||
val unit = size.width / 100f
|
||||
val e = CubicBezierEasing(0.15f, 0.7f, 0.3f, 1f).transform(p)
|
||||
val alpha = if (p < 0.7f) 1f else 1f - (p - 0.7f) / 0.3f
|
||||
val origin = Offset(size.width * 0.3f, size.height * 0.42f)
|
||||
bits.forEach { b ->
|
||||
val at = origin + Offset(b.dx * unit * e, b.dy * unit * e)
|
||||
val s = 3.6f * unit * (0.3f + 0.7f * e)
|
||||
rotate(b.spin * e, at) {
|
||||
val color = tones[b.tone].copy(alpha = alpha)
|
||||
when (b.shape) {
|
||||
0 -> drawCircle(color, s / 2, at)
|
||||
1 -> drawRoundRect(color, at - Offset(s / 2, s / 2), Size(s, s), androidx.compose.ui.geometry.CornerRadius(unit))
|
||||
else -> drawRoundRect(color, at - Offset(s / 2, s / 4), Size(s, s / 2), androidx.compose.ui.geometry.CornerRadius(s / 4))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Bit(val dx: Float, val dy: Float, val spin: Float, val tone: Int, val shape: Int)
|
||||
|
||||
/** One idea per screen: a spinning cookie with an icon, a headline, a line of text, maybe a button. */
|
||||
@Composable
|
||||
private fun BadgeStep(
|
||||
c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, badge: Color,
|
||||
title: String, body: String, key: Any,
|
||||
action: Pair<String, () -> Unit>? = null, outlined: Boolean = false,
|
||||
icon: @Composable () -> Unit,
|
||||
) {
|
||||
val spin by rememberInfiniteTransition(label = "spin").animateFloat(
|
||||
0f, 360f, infiniteRepeatable(tween(18_000, easing = LinearEasing)), label = "spin",
|
||||
)
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(horizontal = cq(8f)),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box(Modifier.size(cq(34f)).pop(entrance(key, 50, 800)), contentAlignment = Alignment.Center) {
|
||||
Cookie(badge, Modifier.fillMaxSize().graphicsLayer { rotationZ = spin })
|
||||
Box(Modifier.size(cq(15f))) { icon() }
|
||||
}
|
||||
Spacer(Modifier.height(cq(8f)))
|
||||
Text(
|
||||
title, color = c.onSurface,
|
||||
style = TextStyle(fontSize = cqSp(13.5f), fontWeight = FontWeight.Black, letterSpacing = cqSp(-0.6f), lineHeight = cqSp(12.5f)),
|
||||
modifier = Modifier.up(entrance(key, 200, 700), cq(7f)),
|
||||
)
|
||||
Spacer(Modifier.height(cq(4f)))
|
||||
Text(
|
||||
body, color = c.onVariant,
|
||||
style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)),
|
||||
modifier = Modifier.up(entrance(key, 350, 700), cq(7f)),
|
||||
)
|
||||
if (action != null) {
|
||||
val (label, onClick) = action
|
||||
Spacer(Modifier.height(if (outlined) cq(7f) else cq(9f)))
|
||||
Text(
|
||||
label,
|
||||
color = if (outlined) c.primary else c.onPrimary,
|
||||
style = TextStyle(fontSize = cqSp(if (outlined) 4.4f else 4.6f), fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier
|
||||
.up(entrance(key, 500, 700), cq(7f))
|
||||
.clip(RoundedCornerShape(50))
|
||||
.then(
|
||||
if (outlined) Modifier.border(BorderStroke(2.dp, c.outline), RoundedCornerShape(50))
|
||||
else Modifier.background(c.primary),
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = if (outlined) cq(7f) else cq(9f), vertical = if (outlined) cq(3.6f) else cq(4.2f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Material's scalloped "cookie": a circle whose radius waves around its edge. */
|
||||
@Composable
|
||||
private fun Cookie(color: Color, modifier: Modifier) {
|
||||
Canvas(modifier) {
|
||||
val unit = size.minDimension / 100f
|
||||
val path = Path()
|
||||
var deg = 0
|
||||
while (deg <= 360) {
|
||||
val a = deg * PI / 180
|
||||
val r = (44 + 4.5 * cos(9 * a)) * unit
|
||||
val x = center.x + (r * cos(a)).toFloat()
|
||||
val y = center.y + (r * sin(a)).toFloat()
|
||||
if (deg == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
deg += 3
|
||||
}
|
||||
path.close()
|
||||
drawPath(path, color)
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws in the mockup's 48-unit icon box. */
|
||||
private inline fun DrawScope.inIconBox(block: DrawScope.() -> Unit) {
|
||||
val s = size.minDimension / 48f
|
||||
scale(s, s, Offset.Zero) { block() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WifiIcon(color: Color) {
|
||||
val t = rememberInfiniteTransition(label = "wifi")
|
||||
val waves = (0..2).map { i ->
|
||||
t.animateFloat(
|
||||
0.35f, 1f,
|
||||
infiniteRepeatable(tween(800, delayMillis = 0, easing = FastOutSlowInEasing), RepeatMode.Reverse,
|
||||
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 200)),
|
||||
label = "wave$i",
|
||||
)
|
||||
}
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
val stroke = Stroke(5f, cap = StrokeCap.Round)
|
||||
// The mockup's three arcs, inner to outer: radius r through (24 ± half, y).
|
||||
listOf(Triple(8f, 6f, 34f), Triple(18f, 13f, 26f), Triple(28f, 20f, 18f)).forEachIndexed { i, (r, half, y) ->
|
||||
val cy = y + sqrt(r * r - half * half)
|
||||
val start = Math.toDegrees(atan2((y - cy).toDouble(), (-half).toDouble())).toFloat()
|
||||
val end = Math.toDegrees(atan2((y - cy).toDouble(), half.toDouble())).toFloat()
|
||||
drawArc(color.copy(alpha = waves[i].value), start, (end - start + 360f) % 360f, false,
|
||||
Offset(24f - r, cy - r), Size(2 * r, 2 * r), style = stroke)
|
||||
}
|
||||
drawCircle(color, 3.6f, Offset(24f, 41f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialIcon(color: Color) {
|
||||
val angle by rememberInfiniteTransition(label = "dial").animateFloat(
|
||||
-70f, -70f,
|
||||
infiniteRepeatable(keyframes {
|
||||
durationMillis = 2600
|
||||
-70f at 0 using CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
|
||||
70f at 1040
|
||||
70f at 1430 using CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
|
||||
-70f at 2470
|
||||
}),
|
||||
label = "dial",
|
||||
)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
drawCircle(color, 19f, Offset(24f, 24f), style = Stroke(5f))
|
||||
rotate(angle, Offset(24f, 24f)) {
|
||||
drawRoundRect(color, Offset(21.5f, 7f), Size(5f, 13f), androidx.compose.ui.geometry.CornerRadius(2.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShieldIcon(color: Color, tick: Color) {
|
||||
val p = entrance(IntroStep.GRAPHENE, 800, 500)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
val shield = Path().apply {
|
||||
moveTo(24f, 4f); lineTo(40f, 10f); lineTo(40f, 22f)
|
||||
cubicTo(40f, 32f, 33f, 40f, 24f, 44f)
|
||||
cubicTo(15f, 40f, 8f, 32f, 8f, 22f)
|
||||
lineTo(8f, 10f); close()
|
||||
}
|
||||
drawPath(shield, color)
|
||||
drawPartialStroke(listOf(Offset(16f, 24f), Offset(22f, 30f), Offset(33f, 18f)), p, tick, 4.5f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeartIcon(color: Color) {
|
||||
val beat by rememberInfiniteTransition(label = "beat").animateFloat(
|
||||
1f, 1f,
|
||||
infiniteRepeatable(keyframes {
|
||||
durationMillis = 1400
|
||||
1f at 0; 1.16f at 210; 1f at 420; 1.1f at 630; 1f at 840
|
||||
}),
|
||||
label = "beat",
|
||||
)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
scale(beat, beat, Offset(24f, 24f)) {
|
||||
val heart = Path().apply {
|
||||
moveTo(24f, 41f)
|
||||
cubicTo(10f, 31f, 5f, 23f, 5f, 16f)
|
||||
cubicTo(5f, 10f, 10f, 6f, 15f, 6f)
|
||||
cubicTo(19f, 6f, 22f, 8f, 24f, 12f)
|
||||
cubicTo(26f, 8f, 29f, 6f, 33f, 6f)
|
||||
cubicTo(38f, 6f, 43f, 10f, 43f, 16f)
|
||||
cubicTo(43f, 23f, 38f, 31f, 24f, 41f)
|
||||
close()
|
||||
}
|
||||
drawPath(heart, color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckIcon(color: Color) {
|
||||
val p = entrance(IntroStep.DRIVE, 300, 500)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox { drawPartialStroke(listOf(Offset(10f, 26f), Offset(20f, 35f), Offset(38f, 14f)), p, color, 5.5f) }
|
||||
}
|
||||
}
|
||||
|
||||
/** A polyline drawn up to [progress] of its length, so a tick can sweep in. */
|
||||
private fun DrawScope.drawPartialStroke(points: List<Offset>, progress: Float, color: Color, width: Float) {
|
||||
if (progress <= 0f) return
|
||||
val lengths = points.zipWithNext { a, b -> (b - a).getDistance() }
|
||||
var left = lengths.sum() * FastOutSlowInEasing.transform(progress.coerceAtMost(1f))
|
||||
val path = Path().apply { moveTo(points[0].x, points[0].y) }
|
||||
for (i in lengths.indices) {
|
||||
if (left <= 0f) break
|
||||
val f = (left / lengths[i]).coerceAtMost(1f)
|
||||
val to = points[i] + (points[i + 1] - points[i]) * f
|
||||
path.lineTo(to.x, to.y)
|
||||
left -= lengths[i]
|
||||
}
|
||||
drawPath(path, color, style = Stroke(width, cap = StrokeCap.Round, join = StrokeJoin.Round))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Dots(c: IntroColors, count: Int, current: Int, cq: (Float) -> Dp, modifier: Modifier) {
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
|
||||
repeat(count) { i ->
|
||||
val on = i == current
|
||||
val width by animateDpAsState(if (on) cq(7f) else cq(2f), tween(300), label = "dot")
|
||||
Box(
|
||||
Modifier
|
||||
.size(width, cq(2f))
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(if (on) c.primary else c.outline.copy(alpha = 0.45f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Tap to continue", once the step has finished arriving, breathing softly. */
|
||||
@Composable
|
||||
private fun Hint(c: IntroColors, cqSp: (Float) -> TextUnit, key: Any, modifier: Modifier) {
|
||||
val shown = entrance(key, 1600, 600)
|
||||
val breathe by rememberInfiniteTransition(label = "hint").animateFloat(
|
||||
0.75f, 0.35f, infiniteRepeatable(tween(1200), RepeatMode.Reverse), label = "hint",
|
||||
)
|
||||
Text(
|
||||
"Tap to continue", color = c.onVariant,
|
||||
style = TextStyle(fontSize = cqSp(3.6f), fontWeight = FontWeight.SemiBold),
|
||||
modifier = modifier.graphicsLayer { alpha = if (shown < 1f) shown * 0.75f else breathe },
|
||||
)
|
||||
}
|
||||
@@ -73,6 +73,7 @@ fun SettingsScreen(
|
||||
onOpenRecommendedApps: () -> Unit,
|
||||
onOpenWhatsNew: () -> Unit,
|
||||
onReplayTutorial: () -> Unit,
|
||||
onReplayIntro: () -> Unit,
|
||||
atUsageNotes: Boolean = false,
|
||||
) {
|
||||
val scroll = rememberScrollState()
|
||||
@@ -105,7 +106,7 @@ fun SettingsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Spacer(Modifier.height(0.dp))
|
||||
AboutSection(onOpenWhatsNew, onReplayTutorial)
|
||||
AboutSection(onOpenWhatsNew, onReplayTutorial, onReplayIntro)
|
||||
ConnectionSection()
|
||||
CarAudioSection()
|
||||
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
||||
@@ -125,7 +126,7 @@ private fun Arrow() = Icon(
|
||||
|
||||
/** What's new, updates and the setup guide, one tap from the top. */
|
||||
@Composable
|
||||
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit) {
|
||||
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit, onReplayIntro: () -> Unit) {
|
||||
SettingsSection {
|
||||
SettingsRow(
|
||||
"What's new",
|
||||
@@ -148,6 +149,16 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni
|
||||
icon = Icons.Filled.Refresh,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Replay the ${Intro.release} intro",
|
||||
Modifier.clickable(onClick = onReplayIntro),
|
||||
subtitle = "What's in this release, in a few taps",
|
||||
icon = Icons.Filled.Star,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
SettingsDivider()
|
||||
AboutLinkRows()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Where Gearslip lives outside the app: donations, the chat room, and the code. */
|
||||
internal object GearslipLinks {
|
||||
const val SPONSORS = "https://github.com/sponsors/Seb3thehacker"
|
||||
const val LIBERAPAY = "https://liberapay.com/Seb3thehacker"
|
||||
const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org"
|
||||
const val SOURCE = "https://github.com/Seb3thehacker/gearslip"
|
||||
|
||||
fun open(context: Context, url: String) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Hide for now" on the home card puts it away for this long; the About page keeps the links. */
|
||||
private const val HIDE_MS = 30L * 24 * 60 * 60 * 1000
|
||||
private const val PREFS = "support"
|
||||
private const val KEY_HIDDEN_UNTIL = "hidden_until"
|
||||
|
||||
private fun hiddenUntil(context: Context) =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getLong(KEY_HIDDEN_UNTIL, 0L)
|
||||
|
||||
/**
|
||||
* The donation ask on Home: always in view, never in the way. It sits below the status and update
|
||||
* cards, and "Hide for now" puts it away for a month.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SupportCard(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
var hidden by remember { mutableStateOf(System.currentTimeMillis() < hiddenUntil(context)) }
|
||||
if (hidden) return
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Surface(
|
||||
modifier,
|
||||
color = scheme.tertiaryContainer,
|
||||
contentColor = scheme.onTertiaryContainer,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Favorite, contentDescription = null)
|
||||
Text(
|
||||
"Support Gearslip",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Gearslip is free, with no ads and no tracking. If it gets you on the road, a " +
|
||||
"donation keeps it going.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putLong(KEY_HIDDEN_UNTIL, System.currentTimeMillis() + HIDE_MS).apply()
|
||||
hidden = true
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) { Text("Hide for now") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
OutlinedButton(onClick = { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) }) { Text("Liberapay") }
|
||||
Button(onClick = { GearslipLinks.open(context, GearslipLinks.SPONSORS) }) { Text("GitHub") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The About section's links: donating, the chat room, and the source code. */
|
||||
@Composable
|
||||
internal fun AboutLinkRows() {
|
||||
val context = LocalContext.current
|
||||
val arrow = @Composable {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
SettingsRow(
|
||||
"Donate on GitHub Sponsors",
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.SPONSORS) },
|
||||
subtitle = "Give once or monthly, through GitHub",
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Donate on Liberapay",
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) },
|
||||
subtitle = "Give weekly or monthly, through a nonprofit",
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Join the discussion on Matrix",
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.MATRIX) },
|
||||
subtitle = "Questions, ideas, or a car to report",
|
||||
icon = Icons.AutoMirrored.Filled.Send,
|
||||
trailing = arrow,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Source code",
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.SOURCE) },
|
||||
subtitle = "Read or change Gearslip's code under the AGPL",
|
||||
icon = Icons.Filled.Build,
|
||||
trailing = arrow,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user