1+ package chat.revolt.activities.voice
2+
3+ import android.os.Bundle
4+ import androidx.activity.ComponentActivity
5+ import androidx.activity.compose.setContent
6+ import androidx.activity.enableEdgeToEdge
7+ import androidx.compose.animation.animateColor
8+ import androidx.compose.animation.core.FastOutSlowInEasing
9+ import androidx.compose.animation.core.RepeatMode
10+ import androidx.compose.animation.core.animateFloatAsState
11+ import androidx.compose.animation.core.infiniteRepeatable
12+ import androidx.compose.animation.core.rememberInfiniteTransition
13+ import androidx.compose.animation.core.tween
14+ import androidx.compose.foundation.background
15+ import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
16+ import androidx.compose.foundation.gestures.AnchoredDraggableState
17+ import androidx.compose.foundation.gestures.DraggableAnchors
18+ import androidx.compose.foundation.gestures.Orientation
19+ import androidx.compose.foundation.gestures.anchoredDraggable
20+ import androidx.compose.foundation.gestures.animateTo
21+ import androidx.compose.foundation.isSystemInDarkTheme
22+ import androidx.compose.foundation.layout.Arrangement
23+ import androidx.compose.foundation.layout.Box
24+ import androidx.compose.foundation.layout.Column
25+ import androidx.compose.foundation.layout.Spacer
26+ import androidx.compose.foundation.layout.aspectRatio
27+ import androidx.compose.foundation.layout.fillMaxHeight
28+ import androidx.compose.foundation.layout.fillMaxSize
29+ import androidx.compose.foundation.layout.fillMaxWidth
30+ import androidx.compose.foundation.layout.height
31+ import androidx.compose.foundation.layout.offset
32+ import androidx.compose.foundation.layout.padding
33+ import androidx.compose.foundation.layout.width
34+ import androidx.compose.foundation.shape.CircleShape
35+ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
36+ import androidx.compose.material3.Icon
37+ import androidx.compose.material3.LocalContentColor
38+ import androidx.compose.material3.MaterialTheme
39+ import androidx.compose.material3.Text
40+ import androidx.compose.runtime.Composable
41+ import androidx.compose.runtime.CompositionLocalProvider
42+ import androidx.compose.runtime.LaunchedEffect
43+ import androidx.compose.runtime.SideEffect
44+ import androidx.compose.runtime.getValue
45+ import androidx.compose.runtime.mutableFloatStateOf
46+ import androidx.compose.runtime.remember
47+ import androidx.compose.runtime.setValue
48+ import androidx.compose.ui.Alignment
49+ import androidx.compose.ui.Modifier
50+ import androidx.compose.ui.draw.clip
51+ import androidx.compose.ui.draw.rotate
52+ import androidx.compose.ui.graphics.Brush
53+ import androidx.compose.ui.graphics.lerp
54+ import androidx.compose.ui.platform.LocalDensity
55+ import androidx.compose.ui.res.painterResource
56+ import androidx.compose.ui.text.font.FontWeight
57+ import androidx.compose.ui.unit.IntOffset
58+ import androidx.compose.ui.unit.dp
59+ import androidx.compose.ui.unit.sp
60+ import chat.revolt.R
61+ import chat.revolt.composables.generic.Presence
62+ import chat.revolt.composables.generic.RemoteImage
63+ import chat.revolt.composables.generic.presenceColour
64+ import chat.revolt.ui.theme.RevoltTheme
65+ import chat.revolt.ui.theme.Theme
66+ import kotlinx.coroutines.delay
67+ import kotlin.math.roundToInt
68+
69+ class IncomingActivity : ComponentActivity () {
70+ override fun onCreate (savedInstanceState : Bundle ? ) {
71+ super .onCreate(savedInstanceState)
72+
73+ enableEdgeToEdge()
74+
75+ setContent {
76+ IncomingCall ()
77+ }
78+ }
79+ }
80+
81+ @Composable
82+ fun IncomingCall () {
83+ RevoltTheme (
84+ requestedTheme = if (isSystemInDarkTheme()) Theme .Revolt else Theme .Light
85+ ) {
86+ CompositionLocalProvider (LocalContentColor provides MaterialTheme .colorScheme.onBackground) {
87+ IncomingCallInner ()
88+ }
89+ }
90+ }
91+
92+ private enum class CallSwiperState {
93+ Initial ,
94+ Accept ,
95+ Decline
96+ }
97+
98+ @OptIn(ExperimentalMaterial3ExpressiveApi ::class )
99+ @Composable
100+ fun IncomingCallInner () {
101+ val bgInfinite = rememberInfiniteTransition(label = " Background" )
102+ val bgColour by bgInfinite.animateColor(
103+ initialValue = MaterialTheme .colorScheme.surfaceContainerHighest,
104+ targetValue = MaterialTheme .colorScheme.surfaceContainerLowest,
105+ label = " Background Colour" ,
106+ animationSpec = infiniteRepeatable(
107+ animation = tween(1000 , 0 , FastOutSlowInEasing ),
108+ repeatMode = RepeatMode .Reverse
109+ )
110+ )
111+
112+ var swiperLabelColourLerp by remember { mutableFloatStateOf(0f ) }
113+ val swiperLabelColourLerpAnim by animateFloatAsState(
114+ targetValue = swiperLabelColourLerp,
115+ animationSpec = tween(250 ),
116+ label = " Swiper Label Colour Lerp state"
117+ )
118+ LaunchedEffect (Unit ) {
119+ while (true ) {
120+ swiperLabelColourLerp = 0f
121+ delay(1000 )
122+ swiperLabelColourLerp = 1f
123+ delay(4000 )
124+ }
125+ }
126+
127+ val swiperState = remember { AnchoredDraggableState (CallSwiperState .Initial ) }
128+ val swiperWidth = 330 .dp
129+ val swiperWidthThird = swiperWidth / 3
130+ val density = LocalDensity .current
131+ val swiperWidthPx = with (density) { swiperWidth.toPx() }
132+ val swiperThirdPx = with (density) { swiperWidthThird.toPx() }
133+ SideEffect {
134+ swiperState.updateAnchors(
135+ DraggableAnchors {
136+ CallSwiperState .Decline at 35f
137+ CallSwiperState .Initial at (swiperWidthPx / 2f ) - (swiperThirdPx / 2f )
138+ CallSwiperState .Accept at swiperWidthPx - swiperThirdPx - 35f
139+ }
140+ )
141+ }
142+
143+ LaunchedEffect (swiperState.currentValue) {
144+ when (swiperState.currentValue) {
145+ CallSwiperState .Accept -> {
146+ swiperState.animateTo(CallSwiperState .Initial )
147+ }
148+
149+ CallSwiperState .Decline -> {
150+ swiperState.animateTo(CallSwiperState .Initial )
151+ }
152+
153+ else -> {}
154+ }
155+ }
156+
157+ val swiperRotation = remember(swiperState.offset) {
158+ if (swiperState.offset.isNaN()) 0f
159+ else {
160+ val declineAnchor = 35f
161+ val initialAnchor = (swiperWidthPx / 2f ) - (swiperThirdPx / 2f )
162+ val acceptAnchor = swiperWidthPx - swiperThirdPx - 35f
163+
164+ when {
165+ swiperState.offset <= initialAnchor -> {
166+ // Moving towards decline: interpolate from 0° to -90°
167+ val progress =
168+ (initialAnchor - swiperState.offset) / (initialAnchor - declineAnchor)
169+ - 225f * progress.coerceIn(0f , 1f )
170+ }
171+
172+ swiperState.offset >= initialAnchor -> {
173+ // Moving towards accept: interpolate from 0° to 90°
174+ val progress =
175+ (swiperState.offset - initialAnchor) / (acceptAnchor - initialAnchor)
176+ 45f * progress.coerceIn(0f , 1f )
177+ }
178+
179+ else -> 0f // At initial position
180+ }
181+ }
182+ }
183+
184+ Box (
185+ Modifier
186+ .fillMaxSize()
187+ .background(bgColour),
188+ contentAlignment = Alignment .Center
189+ ) {
190+ Column (
191+ verticalArrangement = Arrangement .spacedBy(16 .dp, Alignment .CenterVertically ),
192+ horizontalAlignment = Alignment .CenterHorizontally ,
193+ ) {
194+ Text (
195+ " Incoming Call on Revolt" ,
196+ style = MaterialTheme .typography.headlineMedium,
197+ fontSize = 24 .sp
198+ )
199+ Text (
200+ " cat" ,
201+ style = MaterialTheme .typography.displayLargeEmphasized,
202+ fontWeight = FontWeight .SemiBold
203+ )
204+ with (density) {
205+ RemoteImage (
206+ url = " https://cdn.revoltusercontent.com/attachments/K1CDpnvORz2fzUhgq47mcL7N4gccWGqNYYeGaJVvyp/image.png" ,
207+ description = null ,
208+ modifier = Modifier
209+ .clip(CircleShape )
210+ .fillMaxWidth(0.5f )
211+ .aspectRatio(1f ),
212+ )
213+ }
214+ Spacer (Modifier .fillMaxHeight(.33f ))
215+ Box (
216+ modifier = Modifier
217+ .clip(CircleShape )
218+ .height(84 .dp)
219+ .width(swiperWidth)
220+ .then(
221+ if (isSystemInDarkTheme()) Modifier
222+ .background(MaterialTheme .colorScheme.surfaceVariant)
223+ .background(
224+ Brush .linearGradient(
225+ listOf (
226+ presenceColour(Presence .Dnd ).copy(alpha = 0.05f ),
227+ presenceColour(Presence .Online ).copy(alpha = 0.05f ),
228+ )
229+ )
230+ ) else Modifier .background(MaterialTheme .colorScheme.surfaceBright)
231+ ),
232+ ) {
233+ Text (
234+ " Decline" ,
235+ color = lerp(
236+ presenceColour(Presence .Dnd ),
237+ MaterialTheme .colorScheme.onSurface,
238+ swiperLabelColourLerpAnim
239+ ),
240+ modifier = Modifier
241+ .align(Alignment .CenterStart )
242+ .padding(start = 30 .dp)
243+ )
244+ Text (
245+ " Accept" ,
246+ color = lerp(
247+ presenceColour(Presence .Online ),
248+ MaterialTheme .colorScheme.onSurface,
249+ swiperLabelColourLerpAnim
250+ ),
251+ modifier = Modifier
252+ .align(Alignment .CenterEnd )
253+ .padding(end = 30 .dp)
254+ )
255+ Box (
256+ contentAlignment = Alignment .Center ,
257+ modifier = Modifier
258+ .width(swiperWidthThird)
259+ .height(64 .dp)
260+ .offset {
261+ IntOffset (
262+ x = swiperState.requireOffset().roundToInt(),
263+ y = with (density) { (84 .dp - 64 .dp).toPx() / 2 }.roundToInt()
264+ )
265+ }
266+ .anchoredDraggable(
267+ swiperState,
268+ Orientation .Horizontal ,
269+ flingBehavior =
270+ AnchoredDraggableDefaults .flingBehavior(
271+ swiperState,
272+ positionalThreshold = { distance -> distance * 0.25f },
273+ ),
274+ )
275+ .clip(CircleShape )
276+ .background(MaterialTheme .colorScheme.tertiaryContainer)
277+ ) {
278+ Icon (
279+ painter = painterResource(R .drawable.icn_call_24dp__fill),
280+ contentDescription = null ,
281+ tint = MaterialTheme .colorScheme.onTertiaryContainer,
282+ modifier = Modifier
283+ .rotate(swiperRotation)
284+ )
285+ }
286+ }
287+ }
288+ }
289+ }
0 commit comments