Skip to content

Commit c51ad61

Browse files
committed
feat: Shared app logo fidget spinner in about
1 parent 2195b8e commit c51ad61

3 files changed

Lines changed: 190 additions & 117 deletions

File tree

app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,27 @@ import androidx.compose.foundation.layout.padding
1515
import androidx.compose.foundation.layout.size
1616
import androidx.compose.foundation.layout.width
1717
import androidx.compose.foundation.shape.RoundedCornerShape
18+
import androidx.compose.animation.AnimatedContent
19+
import androidx.compose.animation.fadeIn
20+
import androidx.compose.animation.fadeOut
21+
import androidx.compose.animation.togetherWith
1822
import androidx.compose.material3.Button
1923
import androidx.compose.material3.Icon
2024
import androidx.compose.material3.MaterialTheme
2125
import androidx.compose.material3.OutlinedButton
2226
import androidx.compose.material3.Surface
2327
import androidx.compose.material3.Text
2428
import androidx.compose.runtime.Composable
29+
import androidx.compose.runtime.getValue
30+
import androidx.compose.runtime.mutableStateOf
31+
import androidx.compose.runtime.remember
32+
import androidx.compose.runtime.setValue
2533
import androidx.compose.ui.Alignment
2634
import androidx.compose.ui.Modifier
2735
import androidx.compose.ui.draw.clip
2836
import androidx.compose.ui.layout.ContentScale
2937
import androidx.compose.ui.platform.LocalContext
38+
import androidx.compose.ui.platform.LocalHapticFeedback
3039
import androidx.compose.ui.res.painterResource
3140
import androidx.compose.ui.text.style.TextAlign
3241
import androidx.compose.ui.unit.dp
@@ -43,6 +52,8 @@ fun AboutSection(
4352
description: String = "AirSync enables seamless synchronization between your Android device and Mac. Share notifications, clipboard content, and device status wirelessly over your local network."
4453
) {
4554
val context = LocalContext.current
55+
val haptics = LocalHapticFeedback.current
56+
var showAppLogo by remember { mutableStateOf(false) }
4657

4758
val versionName = try {
4859
context.packageManager.getPackageInfo(context.packageName, 0).versionName
@@ -73,21 +84,40 @@ fun AboutSection(
7384
color = MaterialTheme.colorScheme.onSurfaceVariant
7485
)
7586

76-
Image(
77-
painter = painterResource(id = R.drawable.avatar),
78-
contentDescription = "Developer Avatar",
79-
contentScale = ContentScale.Crop,
80-
modifier = Modifier
81-
.size(120.dp)
82-
.clip(RoundedCornerShape(32.dp))
83-
.background(MaterialTheme.colorScheme.primary)
84-
.combinedClickable(
85-
onClick = { },
86-
onLongClick = {
87-
onAvatarLongClick()
88-
}
87+
AnimatedContent(
88+
targetState = showAppLogo,
89+
transitionSpec = {
90+
fadeIn() togetherWith fadeOut()
91+
},
92+
label = "AvatarLogoToggle"
93+
) { isLogoVisible ->
94+
if (isLogoVisible) {
95+
RotatingAppIcon(
96+
haptics = haptics,
97+
modifier = Modifier
98+
.size(200.dp)
99+
.combinedClickable(
100+
onClick = { showAppLogo = false },
101+
onLongClick = { onAvatarLongClick() }
102+
),
103+
isVisible = true
89104
)
90-
)
105+
} else {
106+
Image(
107+
painter = painterResource(id = R.drawable.avatar),
108+
contentDescription = "Developer Avatar",
109+
contentScale = ContentScale.Crop,
110+
modifier = Modifier
111+
.size(120.dp)
112+
.clip(RoundedCornerShape(32.dp))
113+
.background(MaterialTheme.colorScheme.primary)
114+
.combinedClickable(
115+
onClick = { showAppLogo = true },
116+
onLongClick = { onAvatarLongClick() }
117+
)
118+
)
119+
}
120+
}
91121

92122
Text(
93123
text = "Developed by $developerName",
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package com.sameerasw.airsync.presentation.ui.components
2+
3+
import android.content.Context
4+
import android.content.Intent
5+
import android.hardware.Sensor
6+
import android.hardware.SensorEvent
7+
import android.hardware.SensorEventListener
8+
import android.hardware.SensorManager
9+
import androidx.compose.animation.core.Animatable
10+
import androidx.compose.animation.core.Spring
11+
import androidx.compose.animation.core.spring
12+
import androidx.compose.foundation.Image
13+
import androidx.compose.foundation.layout.size
14+
import androidx.compose.runtime.*
15+
import androidx.compose.ui.Modifier
16+
import androidx.compose.ui.graphics.graphicsLayer
17+
import androidx.compose.ui.platform.LocalContext
18+
import androidx.compose.ui.platform.LocalLifecycleOwner
19+
import androidx.compose.ui.res.painterResource
20+
import androidx.compose.ui.unit.dp
21+
import androidx.core.net.toUri
22+
import androidx.lifecycle.Lifecycle
23+
import androidx.lifecycle.LifecycleEventObserver
24+
import com.sameerasw.airsync.R
25+
import com.sameerasw.airsync.utils.HapticUtil
26+
import kotlinx.coroutines.launch
27+
import kotlin.math.PI
28+
import kotlin.math.atan2
29+
30+
@Composable
31+
fun RotatingAppIcon(
32+
modifier: Modifier = Modifier,
33+
haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
34+
hasTriggeredEasterEgg: Boolean = false,
35+
onEasterEggTriggered: () -> Unit = {},
36+
isVisible: Boolean = true
37+
) {
38+
val context = LocalContext.current
39+
val scope = rememberCoroutineScope()
40+
val rotationAnimatable = remember { Animatable(0f) }
41+
42+
val sensorManager = remember { context.getSystemService(Context.SENSOR_SERVICE) as SensorManager }
43+
val gravitySensor = remember { sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) }
44+
var accumulatedRotation by remember { mutableFloatStateOf(0f) }
45+
var lastAngle by remember { mutableFloatStateOf(0f) }
46+
val minorStep = 10f
47+
var lastHapticRotation by remember { mutableFloatStateOf(0f) }
48+
49+
var smoothedAx by remember { mutableFloatStateOf(0f) }
50+
var smoothedAy by remember { mutableFloatStateOf(9.8f) }
51+
val alpha = 0.1f
52+
53+
val lifecycleOwner = LocalLifecycleOwner.current
54+
55+
DisposableEffect(lifecycleOwner, isVisible) {
56+
if (!isVisible) {
57+
onDispose { }
58+
} else {
59+
val listener = object : SensorEventListener {
60+
override fun onSensorChanged(event: SensorEvent) {
61+
if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
62+
val ax = event.values[0]
63+
val ay = event.values[1]
64+
65+
smoothedAx = smoothedAx + alpha * (ax - smoothedAx)
66+
smoothedAy = smoothedAy + alpha * (ay - smoothedAy)
67+
68+
val tiltMagnitudeSqr = smoothedAx * smoothedAx + smoothedAy * smoothedAy
69+
if (tiltMagnitudeSqr < 2.0f) return
70+
71+
val targetAngle = (atan2(smoothedAx.toDouble(), smoothedAy.toDouble()) * 180 / PI).toFloat()
72+
73+
var delta = targetAngle - lastAngle
74+
if (delta > 180) delta -= 360
75+
if (delta < -180) delta += 360
76+
77+
accumulatedRotation += delta
78+
lastAngle = targetAngle
79+
80+
if (kotlin.math.abs(accumulatedRotation - lastHapticRotation) >= minorStep) {
81+
HapticUtil.performLightTick(haptics)
82+
lastHapticRotation = accumulatedRotation
83+
}
84+
85+
if (!hasTriggeredEasterEgg && kotlin.math.abs(accumulatedRotation) >= 3600f) {
86+
onEasterEggTriggered()
87+
val rickRollUrl = "https://youtu.be/dQw4w9WgXcQ"
88+
val intent = Intent(Intent.ACTION_VIEW, rickRollUrl.toUri())
89+
context.startActivity(intent)
90+
}
91+
92+
scope.launch {
93+
rotationAnimatable.animateTo(
94+
accumulatedRotation,
95+
animationSpec = spring(
96+
dampingRatio = Spring.DampingRatioMediumBouncy,
97+
stiffness = Spring.StiffnessLow
98+
)
99+
)
100+
}
101+
}
102+
}
103+
104+
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
105+
}
106+
107+
val observer = LifecycleEventObserver { _, event ->
108+
when (event) {
109+
Lifecycle.Event.ON_RESUME -> {
110+
sensorManager.registerListener(listener, gravitySensor, SensorManager.SENSOR_DELAY_UI)
111+
}
112+
Lifecycle.Event.ON_PAUSE -> {
113+
sensorManager.unregisterListener(listener)
114+
}
115+
else -> {}
116+
}
117+
}
118+
119+
lifecycleOwner.lifecycle.addObserver(observer)
120+
// Initial register if already resumed
121+
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
122+
sensorManager.registerListener(listener, gravitySensor, SensorManager.SENSOR_DELAY_UI)
123+
}
124+
125+
onDispose {
126+
lifecycleOwner.lifecycle.removeObserver(observer)
127+
sensorManager.unregisterListener(listener)
128+
}
129+
}
130+
}
131+
132+
Image(
133+
painter = painterResource(id = R.drawable.ic_launcher_foreground),
134+
contentDescription = null,
135+
modifier = modifier
136+
.graphicsLayer {
137+
rotationZ = rotationAnimatable.value
138+
}
139+
)
140+
}

app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt

Lines changed: 6 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
package com.sameerasw.airsync.presentation.ui.composables
22

3-
import android.content.Context
43
import android.content.Intent
54
import android.content.res.Configuration
6-
import android.hardware.Sensor
7-
import android.hardware.SensorEvent
8-
import android.hardware.SensorEventListener
9-
import android.hardware.SensorManager
105
import android.os.Build
116
import androidx.compose.animation.*
127
import androidx.compose.animation.core.*
@@ -16,10 +11,7 @@ import androidx.compose.foundation.layout.*
1611
import androidx.compose.foundation.shape.RoundedCornerShape
1712
import androidx.compose.material3.*
1813
import androidx.compose.runtime.*
19-
import androidx.compose.runtime.*
2014
import androidx.compose.runtime.collectAsState
21-
import androidx.compose.runtime.getValue
22-
import androidx.compose.runtime.setValue
2315
import androidx.compose.ui.Alignment
2416
import androidx.compose.ui.Modifier
2517
import androidx.compose.ui.draw.clip
@@ -33,9 +25,6 @@ import androidx.compose.ui.hapticfeedback.HapticFeedback
3325
import androidx.compose.ui.platform.LocalConfiguration
3426
import androidx.compose.ui.platform.LocalContext
3527
import androidx.compose.ui.platform.LocalHapticFeedback
36-
import androidx.compose.ui.platform.LocalLifecycleOwner
37-
import androidx.lifecycle.Lifecycle
38-
import androidx.lifecycle.LifecycleEventObserver
3928
import androidx.compose.ui.res.painterResource
4029
import androidx.compose.ui.res.stringResource
4130
import androidx.compose.ui.text.font.FontWeight
@@ -52,6 +41,7 @@ import com.sameerasw.airsync.R
5241
import com.sameerasw.airsync.presentation.ui.components.HelpAndGuidesContent
5342
import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem
5443
import com.sameerasw.airsync.presentation.ui.components.pickers.CrashReportingPicker
44+
import com.sameerasw.airsync.presentation.ui.components.RotatingAppIcon
5545
import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer
5646
import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel
5747
import com.sameerasw.airsync.ui.theme.GoogleSansFlex
@@ -173,9 +163,6 @@ fun WelcomeStepContent(
173163
onNext: () -> Unit
174164
) {
175165
val context = LocalContext.current
176-
val scope = rememberCoroutineScope()
177-
val rotationAnimatable = remember { Animatable(0f) }
178-
179166
Column(
180167
modifier = Modifier.fillMaxSize()
181168
) {
@@ -191,95 +178,11 @@ fun WelcomeStepContent(
191178

192179
Spacer(modifier = Modifier.weight(1f))
193180

194-
val sensorManager = remember { context.getSystemService(Context.SENSOR_SERVICE) as SensorManager }
195-
val gravitySensor = remember { sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) }
196-
var accumulatedRotation by remember { mutableFloatStateOf(0f) }
197-
var lastAngle by remember { mutableFloatStateOf(0f) }
198-
val minorStep = 10f // Increased step for less frequent ticks
199-
var lastHapticRotation by remember { mutableFloatStateOf(0f) }
200-
201-
var smoothedAx by remember { mutableFloatStateOf(0f) }
202-
var smoothedAy by remember { mutableFloatStateOf(9.8f) }
203-
val alpha = 0.1f
204-
205-
val lifecycleOwner = LocalLifecycleOwner.current
206-
207-
DisposableEffect(lifecycleOwner) {
208-
val listener = object : SensorEventListener {
209-
override fun onSensorChanged(event: SensorEvent) {
210-
if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
211-
val ax = event.values[0]
212-
val ay = event.values[1]
213-
val az = event.values[2]
214-
215-
smoothedAx = smoothedAx + alpha * (ax - smoothedAx)
216-
smoothedAy = smoothedAy + alpha * (ay - smoothedAy)
217-
218-
val tiltMagnitudeSqr = smoothedAx * smoothedAx + smoothedAy * smoothedAy
219-
if (tiltMagnitudeSqr < 2.0f) return
220-
221-
val targetAngle = (atan2(smoothedAx.toDouble(), smoothedAy.toDouble()) * 180 / PI).toFloat()
222-
223-
var delta = targetAngle - lastAngle
224-
if (delta > 180) delta -= 360
225-
if (delta < -180) delta += 360
226-
227-
accumulatedRotation += delta
228-
lastAngle = targetAngle
229-
230-
if (kotlin.math.abs(accumulatedRotation - lastHapticRotation) >= minorStep) {
231-
HapticUtil.performLightTick(haptics)
232-
lastHapticRotation = accumulatedRotation
233-
}
234-
235-
if (!hasTriggeredEasterEgg && kotlin.math.abs(accumulatedRotation) >= 3600f) {
236-
onEasterEggTriggered()
237-
val rickRollUrl = "https://youtu.be/dQw4w9WgXcQ"
238-
val intent = Intent(Intent.ACTION_VIEW, rickRollUrl.toUri())
239-
context.startActivity(intent)
240-
}
241-
242-
scope.launch {
243-
rotationAnimatable.animateTo(
244-
accumulatedRotation,
245-
animationSpec = spring(
246-
dampingRatio = Spring.DampingRatioMediumBouncy,
247-
stiffness = Spring.StiffnessLow
248-
)
249-
)
250-
}
251-
}
252-
}
253-
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
254-
}
255-
256-
val observer = LifecycleEventObserver { _, event ->
257-
when (event) {
258-
Lifecycle.Event.ON_RESUME -> {
259-
sensorManager.registerListener(listener, gravitySensor, SensorManager.SENSOR_DELAY_UI)
260-
}
261-
Lifecycle.Event.ON_PAUSE -> {
262-
sensorManager.unregisterListener(listener)
263-
}
264-
else -> {}
265-
}
266-
}
267-
268-
lifecycleOwner.lifecycle.addObserver(observer)
269-
onDispose {
270-
lifecycleOwner.lifecycle.removeObserver(observer)
271-
sensorManager.unregisterListener(listener)
272-
}
273-
}
274-
275-
Image(
276-
painter = painterResource(id = R.drawable.ic_launcher_foreground),
277-
contentDescription = null,
278-
modifier = Modifier
279-
.size(240.dp)
280-
.graphicsLayer {
281-
rotationZ = rotationAnimatable.value
282-
}
181+
RotatingAppIcon(
182+
haptics = haptics,
183+
hasTriggeredEasterEgg = hasTriggeredEasterEgg,
184+
onEasterEggTriggered = onEasterEggTriggered,
185+
modifier = Modifier.size(240.dp)
283186
)
284187

285188
Spacer(modifier = Modifier.height(18.dp))

0 commit comments

Comments
 (0)