Last active
January 14, 2026 22:01
-
-
Save brkckr/77c7c878f79144bc8a7a792cd30bdb0a to your computer and use it in GitHub Desktop.
a cheap copy of speedtest on android/jetpack compose
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @Composable | |
| fun Speedometer(speed: Float) { | |
| // Text measurer for drawing numbers | |
| val textMeasurer = rememberTextMeasurer() | |
| Box( | |
| contentAlignment = Alignment.Center, | |
| modifier = Modifier.size(400.dp) | |
| ) { | |
| Canvas(modifier = Modifier.fillMaxSize()) { | |
| val radius = min(size.width, size.height) * 0.4f // arc's radius | |
| val needleLength = 250f | |
| val stroke = 20.dp.toPx() | |
| val startAngle = 150f | |
| val sweepAngel = 240f | |
| drawArc( | |
| color = Color(0xFF1f2642), | |
| startAngle = startAngle, | |
| sweepAngle = sweepAngel, | |
| useCenter = false, | |
| style = Stroke(width = stroke), | |
| topLeft = Offset(center.x - radius, center.y - radius), | |
| size = Size(radius * 2, radius * 2) | |
| ) | |
| drawArc( | |
| brush = Brush.linearGradient( | |
| colors = listOf( | |
| Color(0xFF01a3e3), | |
| Color(0xFF0eb3a5) | |
| ), | |
| start = Offset( | |
| center.x + radius * cos(Math.toRadians(150.0)).toFloat(), | |
| center.y + radius * sin(Math.toRadians(150.0)).toFloat() | |
| ), | |
| end = Offset( | |
| center.x + radius * cos(Math.toRadians(150.0 + (speed / 100) * 240.0)).toFloat(), | |
| center.y + radius * sin(Math.toRadians(150.0 + (speed / 100) * 240.0)).toFloat() | |
| ) | |
| ), | |
| startAngle = startAngle, | |
| sweepAngle = (speed / 100) * 240f, | |
| useCenter = false, | |
| style = Stroke(width = stroke), | |
| topLeft = Offset(center.x - radius, center.y - radius), | |
| size = Size(radius * 2, radius * 2) | |
| ) | |
| drawArc( | |
| brush = Brush.linearGradient( | |
| colors = listOf( | |
| Color(0x4001a3e3), | |
| Color(0x1001a3e3) | |
| ), | |
| start = Offset( | |
| center.x + radius * cos(Math.toRadians(150.0)).toFloat(), | |
| center.y + radius * sin(Math.toRadians(150.0)).toFloat() | |
| ), | |
| end = Offset( | |
| center.x + radius * cos(Math.toRadians(150.0 + (speed / 100) * 240.0)).toFloat(), | |
| center.y + radius * sin(Math.toRadians(150.0 + (speed / 100) * 240.0)).toFloat() | |
| ) | |
| ), | |
| startAngle = startAngle, | |
| sweepAngle = (speed / 100) * 240f, | |
| useCenter = false, | |
| style = Stroke(width = stroke), | |
| topLeft = Offset(center.x - radius + stroke, center.y - radius + stroke), | |
| size = Size((radius - stroke) * 2, (radius - stroke) * 2) | |
| ) | |
| val angle = 150 + (speed / 100) * 240 | |
| val rad = Math.toRadians(angle.toDouble()) | |
| val needleEnd = Offset( | |
| center.x + needleLength * cos(rad).toFloat(), | |
| center.y + needleLength * sin(rad).toFloat() | |
| ) | |
| val baseWidth = 40f // Wide base | |
| val tipWidth = 10f // Narrow tip | |
| val perpendicularAngle = rad + Math.PI / 2 // Perpendicular to needle direction | |
| // Calculate trapezoid points | |
| val baseLeft = Offset( | |
| center.x + (baseWidth / 2) * cos(perpendicularAngle).toFloat(), | |
| center.y + (baseWidth / 2) * sin(perpendicularAngle).toFloat() | |
| ) | |
| val baseRight = Offset( | |
| center.x - (baseWidth / 2) * cos(perpendicularAngle).toFloat(), | |
| center.y - (baseWidth / 2) * sin(perpendicularAngle).toFloat() | |
| ) | |
| val tipLeft = Offset( | |
| needleEnd.x + (tipWidth / 2) * cos(perpendicularAngle).toFloat(), | |
| needleEnd.y + (tipWidth / 2) * sin(perpendicularAngle).toFloat() | |
| ) | |
| val tipRight = Offset( | |
| needleEnd.x - (tipWidth / 2) * cos(perpendicularAngle).toFloat(), | |
| needleEnd.y - (tipWidth / 2) * sin(perpendicularAngle).toFloat() | |
| ) | |
| // Create trapezoid path | |
| val path = Path().apply { | |
| moveTo(baseLeft.x, baseLeft.y) | |
| lineTo(tipLeft.x, tipLeft.y) | |
| lineTo(tipRight.x, tipRight.y) | |
| lineTo(baseRight.x, baseRight.y) | |
| close() | |
| } | |
| // Draw trapezoid needle | |
| drawPath( | |
| path = path, | |
| brush = Brush.linearGradient( | |
| colors = listOf( | |
| Color.White.copy(alpha = 0.3f), | |
| Color.White.copy(alpha = 0.5f), | |
| Color.White | |
| ), | |
| start = center, | |
| end = needleEnd | |
| ) | |
| ) | |
| // Draw only 6 numbers | |
| val numbers = listOf(0, 20, 40, 60, 80, 100) | |
| val currentSweepAngle = (speed / 100) * 240f // Current arc progress in degrees | |
| for (value in numbers) { | |
| val textAngle = 150 + (value / 100f) * 240 // Map value to arc angle | |
| val rad = Math.toRadians(textAngle.toDouble()) | |
| val textX = center.x + radius * 0.8f * cos(rad).toFloat() | |
| val textY = center.y + radius * 0.8f * sin(rad).toFloat() | |
| // Calculate color based on arc progress | |
| val numberSweepAngle = (value / 100f) * 240f // Sweep angle for this number | |
| val alpha = if (currentSweepAngle >= numberSweepAngle) { | |
| 1.0f // Full white when arc reaches or passes the number | |
| } else { | |
| 0.5f // Semi-transparent white if arc hasn't reached | |
| } | |
| // Measure and draw text | |
| val textLayoutResult = textMeasurer.measure( | |
| text = value.toString(), | |
| style = TextStyle( | |
| color = Color.White.copy(alpha = alpha), | |
| fontSize = 14.sp, | |
| fontWeight = FontWeight.Medium | |
| ) | |
| ) | |
| // Draw text horizontally, no rotation | |
| drawText( | |
| textLayoutResult = textLayoutResult, | |
| topLeft = Offset( | |
| textX - textLayoutResult.size.width / 2, | |
| textY - textLayoutResult.size.height / 2 | |
| ) | |
| ) | |
| } | |
| // Draw download speed at the bottom of the Canvas | |
| val downloadText = "${"%.1f".format(speed)} Mbps" | |
| val downloadTextLayoutResult = textMeasurer.measure( | |
| text = downloadText, | |
| style = TextStyle( | |
| color = Color.White, | |
| fontSize = 24.sp, | |
| fontWeight = FontWeight.Bold | |
| ) | |
| ) | |
| drawText( | |
| textLayoutResult = downloadTextLayoutResult, | |
| topLeft = Offset( | |
| center.x - downloadTextLayoutResult.size.width / 2, | |
| size.height * 0.7f | |
| ) | |
| ) | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @Composable | |
| fun SpeedTestScreen() { | |
| // State to track if the test is running | |
| var isTesting by remember { mutableStateOf(false) } | |
| // State for download speed | |
| var downloadSpeed by remember { mutableFloatStateOf(0f) } | |
| // Animated speed value for smooth transitions | |
| val animatedSpeed by animateFloatAsState( | |
| targetValue = if (isTesting) downloadSpeed else 0f, | |
| animationSpec = tween(durationMillis = 2000) | |
| ) | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(colorResource(R.color.background)) | |
| .padding(16.dp), | |
| horizontalAlignment = Alignment.CenterHorizontally, | |
| verticalArrangement = Arrangement.SpaceEvenly | |
| ) { | |
| // Speedometer component | |
| Speedometer(speed = animatedSpeed) | |
| // Start/Stop button | |
| Button( | |
| onClick = { | |
| isTesting = !isTesting | |
| downloadSpeed = if (isTesting) { | |
| // Simulate speed test with random values | |
| (10..100).random().toFloat() | |
| } else { | |
| // Reset values when test stops | |
| 0f | |
| } | |
| }, | |
| modifier = Modifier | |
| .padding(16.dp) | |
| .width(200.dp) | |
| ) { | |
| Text(text = if (isTesting) "Stop" else "Start", fontSize = 24.sp) | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @Composable | |
| fun HomeScreen(navigate: (String) -> Unit) { | |
| // state to control which animation is active | |
| var isHeartbeatActive by remember { mutableStateOf(true) } | |
| // heartbeat animation | |
| val heartbeatScale = remember { Animatable(1f) } | |
| // ripple animation | |
| val rippleScale = remember { Animatable(1f) } | |
| val rippleAlpha = remember { Animatable(1f) } | |
| // manage animation sequence | |
| LaunchedEffect(Unit) { | |
| while (true) { | |
| // run heartbeat animation | |
| isHeartbeatActive = true | |
| heartbeatScale.animateTo( | |
| targetValue = 0.9f, | |
| animationSpec = tween(300, easing = FastOutSlowInEasing) | |
| ) | |
| heartbeatScale.animateTo( | |
| targetValue = 1f, | |
| animationSpec = tween(300, easing = FastOutSlowInEasing) | |
| ) | |
| // run ripple animation | |
| isHeartbeatActive = false | |
| rippleScale.snapTo(1f) | |
| rippleAlpha.snapTo(1f) | |
| coroutineScope { | |
| launch { | |
| rippleScale.animateTo( | |
| targetValue = 1.5f, | |
| animationSpec = tween(1300, easing = LinearEasing) | |
| ) | |
| } | |
| launch { | |
| rippleAlpha.animateTo( | |
| targetValue = 0f, | |
| animationSpec = tween(1300, easing = LinearEasing) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(colorResource(R.color.background)), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| // draw ripple and heartbeat animations | |
| Canvas(modifier = Modifier.size(200.dp)) { | |
| val baseCircleSize = size.width * 0.8f | |
| val center = Offset(size.width / 2, size.height / 2) | |
| // draw ripple effect only when heartbeat is inactive | |
| if (!isHeartbeatActive) { | |
| drawCircle( | |
| color = Color(0xFF19ab77).copy(alpha = rippleAlpha.value), | |
| radius = baseCircleSize * rippleScale.value / 2, | |
| center = center, | |
| style = Stroke(width = 8f) | |
| ) | |
| } | |
| // draw static circle with heartbeat effect | |
| drawCircle( | |
| color = Color(0xFF19ab77), | |
| radius = (baseCircleSize / 2) * heartbeatScale.value, | |
| center = center, | |
| style = Stroke(width = 10f) | |
| ) | |
| } | |
| // GO text | |
| Text( | |
| text = "GO", | |
| color = Color.White, | |
| fontSize = 48.sp, | |
| fontWeight = FontWeight.Bold, | |
| modifier = Modifier | |
| .clickable { navigate("speedtest") } | |
| .padding(16.dp) | |
| ) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
