Skip to content

Commit 4d96d35

Browse files
authored
Merge pull request #82 from nsh07/always-on-display
feat(ui): Always On Display (AOD) mode
2 parents 460403e + 4293f0d commit 4d96d35

File tree

14 files changed

+584
-189
lines changed

14 files changed

+584
-189
lines changed

app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ android {
3131

3232
defaultConfig {
3333
applicationId = "org.nsh07.pomodoro"
34-
minSdk = 26
34+
minSdk = 27
3535
targetSdk = 36
3636
versionCode = 13
3737
versionName = "1.5.0"

app/src/main/java/org/nsh07/pomodoro/MainActivity.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ class MainActivity : ComponentActivity() {
5151
appContainer.appTimerRepository.colorScheme = colorScheme
5252
}
5353

54-
AppScreen(timerViewModel = timerViewModel)
54+
AppScreen(
55+
timerViewModel = timerViewModel,
56+
isAODEnabled = preferencesState.aodEnabled
57+
)
5558
}
5659
}
5760
}
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
/*
2+
* Copyright (c) 2025 Nishant Mishra
3+
*
4+
* You should have received a copy of the GNU General Public License
5+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
6+
*/
7+
8+
package org.nsh07.pomodoro.ui
9+
10+
import android.app.Activity
11+
import android.view.WindowManager
12+
import androidx.activity.compose.LocalActivity
13+
import androidx.compose.animation.SharedTransitionLayout
14+
import androidx.compose.animation.SharedTransitionScope
15+
import androidx.compose.animation.animateColorAsState
16+
import androidx.compose.animation.core.animateIntAsState
17+
import androidx.compose.foundation.background
18+
import androidx.compose.foundation.layout.Box
19+
import androidx.compose.foundation.layout.fillMaxSize
20+
import androidx.compose.foundation.layout.offset
21+
import androidx.compose.foundation.layout.size
22+
import androidx.compose.material3.CircularProgressIndicator
23+
import androidx.compose.material3.CircularWavyProgressIndicator
24+
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
25+
import androidx.compose.material3.MaterialTheme.colorScheme
26+
import androidx.compose.material3.MaterialTheme.motionScheme
27+
import androidx.compose.material3.Text
28+
import androidx.compose.runtime.Composable
29+
import androidx.compose.runtime.DisposableEffect
30+
import androidx.compose.runtime.LaunchedEffect
31+
import androidx.compose.runtime.getValue
32+
import androidx.compose.runtime.mutableIntStateOf
33+
import androidx.compose.runtime.mutableStateOf
34+
import androidx.compose.runtime.remember
35+
import androidx.compose.runtime.setValue
36+
import androidx.compose.ui.Alignment
37+
import androidx.compose.ui.Modifier
38+
import androidx.compose.ui.graphics.Color
39+
import androidx.compose.ui.graphics.StrokeCap
40+
import androidx.compose.ui.graphics.drawscope.Stroke
41+
import androidx.compose.ui.platform.LocalDensity
42+
import androidx.compose.ui.platform.LocalView
43+
import androidx.compose.ui.platform.LocalWindowInfo
44+
import androidx.compose.ui.text.TextStyle
45+
import androidx.compose.ui.text.font.FontWeight
46+
import androidx.compose.ui.text.style.TextAlign
47+
import androidx.compose.ui.tooling.preview.Preview
48+
import androidx.compose.ui.unit.Density
49+
import androidx.compose.ui.unit.Dp
50+
import androidx.compose.ui.unit.IntOffset
51+
import androidx.compose.ui.unit.dp
52+
import androidx.compose.ui.unit.sp
53+
import androidx.core.view.WindowCompat
54+
import androidx.core.view.WindowInsetsCompat
55+
import androidx.core.view.WindowInsetsControllerCompat
56+
import androidx.navigation3.ui.LocalNavAnimatedContentScope
57+
import kotlinx.coroutines.delay
58+
import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock
59+
import org.nsh07.pomodoro.ui.theme.TomatoTheme
60+
import org.nsh07.pomodoro.ui.timerScreen.TimerScreen
61+
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode
62+
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState
63+
import kotlin.random.Random
64+
65+
/**
66+
* Always On Display composable. Must be called within a [SharedTransitionScope] which allows
67+
* animating the clock and progress indicator
68+
*
69+
* @param timerState [TimerState] instance. This must be the same instance as the one used on the
70+
* root [TimerScreen] composable
71+
* @param progress lambda that returns the current progress of the clock
72+
* randomized offset for the clock to allow smooth motion with sharedBounds
73+
*/
74+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
75+
@Composable
76+
fun SharedTransitionScope.AlwaysOnDisplay(
77+
timerState: TimerState,
78+
progress: () -> Float,
79+
modifier: Modifier = Modifier
80+
) {
81+
var sharedElementTransitionComplete by remember { mutableStateOf(false) }
82+
83+
val activity = LocalActivity.current
84+
val density = LocalDensity.current
85+
val windowInfo = LocalWindowInfo.current
86+
val view = LocalView.current
87+
88+
val window = remember { (view.context as Activity).window }
89+
val insetsController = remember { WindowCompat.getInsetsController(window, view) }
90+
91+
DisposableEffect(Unit) {
92+
window.addFlags(
93+
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
94+
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON
95+
)
96+
activity?.setShowWhenLocked(true)
97+
insetsController.apply {
98+
hide(WindowInsetsCompat.Type.statusBars())
99+
hide(WindowInsetsCompat.Type.navigationBars())
100+
systemBarsBehavior =
101+
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
102+
}
103+
104+
onDispose {
105+
window.clearFlags(
106+
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
107+
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON
108+
)
109+
activity?.setShowWhenLocked(false)
110+
insetsController.apply {
111+
show(WindowInsetsCompat.Type.statusBars())
112+
show(WindowInsetsCompat.Type.navigationBars())
113+
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
114+
}
115+
}
116+
}
117+
118+
LaunchedEffect(Unit) {
119+
delay(300)
120+
sharedElementTransitionComplete = true
121+
}
122+
123+
val primary by animateColorAsState(
124+
if (sharedElementTransitionComplete) Color(0xFFA2A2A2)
125+
else {
126+
if (timerState.timerMode == TimerMode.FOCUS) colorScheme.primary
127+
else colorScheme.tertiary
128+
},
129+
animationSpec = motionScheme.slowEffectsSpec()
130+
)
131+
val secondaryContainer by animateColorAsState(
132+
if (sharedElementTransitionComplete) Color(0xFF1D1D1D)
133+
else {
134+
if (timerState.timerMode == TimerMode.FOCUS) colorScheme.secondaryContainer
135+
else colorScheme.tertiaryContainer
136+
},
137+
animationSpec = motionScheme.slowEffectsSpec()
138+
)
139+
val surface by animateColorAsState(
140+
if (sharedElementTransitionComplete) Color.Black
141+
else colorScheme.surface,
142+
animationSpec = motionScheme.slowEffectsSpec()
143+
)
144+
val onSurface by animateColorAsState(
145+
if (sharedElementTransitionComplete) Color(0xFFE3E3E3)
146+
else colorScheme.onSurface,
147+
animationSpec = motionScheme.slowEffectsSpec()
148+
)
149+
150+
var randomX by remember {
151+
mutableIntStateOf(
152+
Random.nextInt(
153+
16.dp.toIntPx(density),
154+
windowInfo.containerSize.width - 266.dp.toIntPx(density)
155+
)
156+
)
157+
}
158+
var randomY by remember {
159+
mutableIntStateOf(
160+
Random.nextInt(
161+
16.dp.toIntPx(density),
162+
windowInfo.containerSize.height - 266.dp.toIntPx(density)
163+
)
164+
)
165+
}
166+
167+
LaunchedEffect(timerState.timeStr[1]) { // Randomize position every minute
168+
if (sharedElementTransitionComplete) {
169+
randomX = Random.nextInt(
170+
16.dp.toIntPx(density),
171+
windowInfo.containerSize.width - 266.dp.toIntPx(density)
172+
)
173+
randomY = Random.nextInt(
174+
16.dp.toIntPx(density),
175+
windowInfo.containerSize.height - 266.dp.toIntPx(density)
176+
)
177+
}
178+
}
179+
180+
val x by animateIntAsState(randomX, motionScheme.slowSpatialSpec())
181+
val y by animateIntAsState(randomY, motionScheme.slowSpatialSpec())
182+
183+
Box(
184+
modifier = modifier
185+
.fillMaxSize()
186+
.background(surface)
187+
) {
188+
Box(
189+
contentAlignment = Alignment.Center,
190+
modifier = Modifier.offset {
191+
IntOffset(x, y)
192+
}
193+
) {
194+
if (timerState.timerMode == TimerMode.FOCUS) {
195+
CircularProgressIndicator(
196+
progress = progress,
197+
modifier = Modifier
198+
.sharedBounds(
199+
sharedContentState = this@AlwaysOnDisplay.rememberSharedContentState("focus progress"),
200+
animatedVisibilityScope = LocalNavAnimatedContentScope.current
201+
)
202+
.size(250.dp),
203+
color = primary,
204+
trackColor = secondaryContainer,
205+
strokeWidth = 12.dp,
206+
gapSize = 8.dp,
207+
)
208+
} else {
209+
CircularWavyProgressIndicator(
210+
progress = progress,
211+
modifier = Modifier
212+
.sharedBounds(
213+
sharedContentState = this@AlwaysOnDisplay.rememberSharedContentState("break progress"),
214+
animatedVisibilityScope = LocalNavAnimatedContentScope.current
215+
)
216+
.size(250.dp),
217+
color = primary,
218+
trackColor = secondaryContainer,
219+
stroke = Stroke(
220+
width = with(LocalDensity.current) {
221+
12.dp.toPx()
222+
},
223+
cap = StrokeCap.Round,
224+
),
225+
trackStroke = Stroke(
226+
width = with(LocalDensity.current) {
227+
12.dp.toPx()
228+
},
229+
cap = StrokeCap.Round,
230+
),
231+
wavelength = 42.dp,
232+
gapSize = 8.dp
233+
)
234+
}
235+
236+
Text(
237+
text = timerState.timeStr,
238+
style = TextStyle(
239+
fontFamily = openRundeClock,
240+
fontWeight = FontWeight.Bold,
241+
fontSize = 56.sp,
242+
letterSpacing = (-2).sp
243+
),
244+
textAlign = TextAlign.Center,
245+
color = onSurface,
246+
maxLines = 1,
247+
modifier = Modifier.sharedBounds(
248+
sharedContentState = this@AlwaysOnDisplay.rememberSharedContentState("clock"),
249+
animatedVisibilityScope = LocalNavAnimatedContentScope.current
250+
)
251+
)
252+
}
253+
}
254+
}
255+
256+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
257+
@Preview
258+
@Composable
259+
private fun AlwaysOnDisplayPreview() {
260+
val timerState = TimerState()
261+
val progress = { 0.5f }
262+
TomatoTheme {
263+
SharedTransitionLayout {
264+
AlwaysOnDisplay(
265+
timerState = timerState,
266+
progress = progress
267+
)
268+
}
269+
}
270+
}
271+
272+
fun Dp.toIntPx(density: Density) = with(density) { toPx().toInt() }

0 commit comments

Comments
 (0)