From 94ed58e13f638631500f2bc98b16e339748c2e75 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Mon, 12 Oct 2026 00:20:36 -0700 Subject: [PATCH] 0.3 intro, donation links on Home, and About links for Matrix and the source --- .../seb3thehacker/gearslip/ui/GearslipApp.kt | 30 +- .../seb3thehacker/gearslip/ui/HomeScreen.kt | 4 + .../seb3thehacker/gearslip/ui/IntroScreen.kt | 543 ++++++++++++++++++ .../gearslip/ui/SettingsScreen.kt | 15 +- .../seb3thehacker/gearslip/ui/SupportViews.kt | 154 +++++ 5 files changed, 731 insertions(+), 15 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/IntroScreen.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SupportViews.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt index 4d745ed..f3dbd09 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt @@ -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 }) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index bb3a79f..2ca2967 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -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. diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/IntroScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/IntroScreen.kt new file mode 100644 index 0000000..0388b35 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/IntroScreen.kt @@ -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 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, 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 }, + ) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index 90dab03..a33cf85 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -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() } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SupportViews.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SupportViews.kt new file mode 100644 index 0000000..924e0ef --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SupportViews.kt @@ -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, + ) +}