Skip to content

Commit 8a771d1

Browse files
committed
feat: integrate in-app updates with custom UI
This commit introduces in-app update functionality with a custom UI component. Key Changes: - Add `in-app-update-compose` and `in-app-update-compose-mui` dependencies. - Create `AppUpdateButton` composable to display update status and trigger updates. - Shows different states: Hidden, Available, Downloading (with progress), Ready to Install. - Includes specific icons and text for each state. - Uses animations for visual feedback (pulse for "Ready" state). - Integrate `AppUpdateButton` into `MapBottomSheetScaffold`. - The button is displayed at the top center of the screen, below the top app bar. - Maps `InAppUpdateState` from the library to the custom `AppUpdateButtonState`. - Supports both FLEXIBLE and IMMEDIATE update modes based on `InAppUpdateState`. - Add previews for different states of `AppUpdateButton`.
1 parent 1a0d88c commit 8a771d1

4 files changed

Lines changed: 246 additions & 0 deletions

File tree

app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ dependencies {
135135

136136
implementation(libs.androidx.core.splashscreen)
137137

138+
implementation(libs.inAppUpdateCompose.material)
139+
138140
debugImplementation(libs.androidx.ui.tooling)
139141
debugImplementation(libs.androidx.ui.test.manifest)
140142
debugImplementation(libs.chucker)

app/src/main/java/com/sloy/sevibus/MapBottomSheetScaffold.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
2020
import androidx.compose.foundation.layout.height
2121
import androidx.compose.foundation.layout.ime
2222
import androidx.compose.foundation.layout.navigationBarsPadding
23+
import androidx.compose.foundation.layout.padding
2324
import androidx.compose.foundation.layout.systemBars
2425
import androidx.compose.foundation.layout.windowInsetsBottomHeight
2526
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -37,6 +38,7 @@ import androidx.compose.runtime.remember
3738
import androidx.compose.runtime.rememberCoroutineScope
3839
import androidx.compose.runtime.rememberUpdatedState
3940
import androidx.compose.runtime.setValue
41+
import androidx.compose.ui.Alignment
4042
import androidx.compose.ui.Modifier
4143
import androidx.compose.ui.platform.LocalDensity
4244
import androidx.compose.ui.unit.Dp
@@ -48,6 +50,8 @@ import com.sloy.sevibus.navigation.NavigationDestinationType
4850
import com.sloy.sevibus.navigation.TopLevelDestination
4951
import com.sloy.sevibus.navigation.isBottomBarVisible
5052
import com.sloy.sevibus.navigation.isTopBarVisible
53+
import com.sloy.sevibus.ui.components.AppUpdateButton
54+
import com.sloy.sevibus.ui.components.AppUpdateButtonState
5155
import com.sloy.sevibus.ui.components.SevNavigationBar
5256
import com.sloy.sevibus.ui.snackbar.LocalSnackbarHostState
5357
import com.sloy.sevibus.ui.theme.SevTheme
@@ -56,6 +60,9 @@ import io.morfly.compose.bottomsheet.material3.rememberBottomSheetScaffoldState
5660
import io.morfly.compose.bottomsheet.material3.rememberBottomSheetState
5761
import io.morfly.compose.bottomsheet.material3.requireSheetVisibleHeightDp
5862
import kotlinx.coroutines.launch
63+
import se.warting.inappupdate.compose.InAppUpdateState
64+
import se.warting.inappupdate.compose.Mode
65+
import se.warting.inappupdate.compose.rememberInAppUpdateState
5966

6067
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
6168
@Composable
@@ -83,6 +90,8 @@ fun MapBottomSheetScaffold(
8390
CustomSheetValue.Expanded at offset(topInsetPadding)
8491
})
8592

93+
val updateState = rememberInAppUpdateState()
94+
8695
// Bottomsheet expanded state
8796
var previousSheetDestination by remember { mutableStateOf(currentDestination) }
8897
LaunchedEffect(currentDestination) {
@@ -171,6 +180,16 @@ fun MapBottomSheetScaffold(
171180
}
172181
},
173182
)
183+
184+
Box(Modifier.fillMaxWidth()) {
185+
AppUpdateButton(
186+
updateState.toButtonState(),
187+
Modifier
188+
.padding(top = scaffoldInnerPadding.calculateTopPadding())
189+
.padding(8.dp)
190+
.align(Alignment.Center)
191+
)
192+
}
174193
}
175194
AnimatedFullScreenContent(currentDestination) { destination ->
176195
fullScreenContent(destination, scaffoldInnerPadding)
@@ -179,6 +198,20 @@ fun MapBottomSheetScaffold(
179198
}
180199
}
181200

201+
private fun InAppUpdateState.toButtonState(): AppUpdateButtonState {
202+
return when (this) {
203+
is InAppUpdateState.OptionalUpdate -> AppUpdateButtonState.Available(onClick = { this.onStartUpdate(Mode.FLEXIBLE) })
204+
is InAppUpdateState.RequiredUpdate -> AppUpdateButtonState.Available(onClick = { this.onStartUpdate() })
205+
is InAppUpdateState.DownloadedUpdate -> AppUpdateButtonState.Ready(onClick = { this.appUpdateResult.completeUpdate() })
206+
is InAppUpdateState.InProgressUpdate -> AppUpdateButtonState.Downloading(
207+
installState.bytesDownloaded,
208+
installState.totalBytesToDownload
209+
)
210+
211+
else -> AppUpdateButtonState.Hidden
212+
}
213+
}
214+
182215
private fun NavigationDestination.peekingSheetHeight(): Dp {
183216
return when (this) {
184217
is NavigationDestination.ForYou -> 48.dp
@@ -251,4 +284,5 @@ private fun WithRetainedDestination(
251284

252285

253286
private enum class CustomSheetValue { Collapsed, PartiallyExpanded, PartiallyCollapsed, Expanded }
287+
254288
private val SHEET_DRAG_HANDLE_HEIGHT = 48.dp
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package com.sloy.sevibus.ui.components
2+
3+
import androidx.compose.animation.AnimatedVisibility
4+
import androidx.compose.animation.core.FastOutSlowInEasing
5+
import androidx.compose.animation.core.RepeatMode
6+
import androidx.compose.animation.core.animateFloat
7+
import androidx.compose.animation.core.infiniteRepeatable
8+
import androidx.compose.animation.core.rememberInfiniteTransition
9+
import androidx.compose.animation.core.tween
10+
import androidx.compose.animation.fadeIn
11+
import androidx.compose.animation.fadeOut
12+
import androidx.compose.foundation.background
13+
import androidx.compose.foundation.layout.Box
14+
import androidx.compose.foundation.layout.Row
15+
import androidx.compose.foundation.layout.padding
16+
import androidx.compose.foundation.layout.size
17+
import androidx.compose.foundation.shape.CircleShape
18+
import androidx.compose.material.icons.Icons
19+
import androidx.compose.material.icons.filled.InstallMobile
20+
import androidx.compose.material.icons.filled.MobileFriendly
21+
import androidx.compose.material3.CircularProgressIndicator
22+
import androidx.compose.material3.Icon
23+
import androidx.compose.material3.Surface
24+
import androidx.compose.material3.Text
25+
import androidx.compose.material3.contentColorFor
26+
import androidx.compose.runtime.Composable
27+
import androidx.compose.runtime.rememberCoroutineScope
28+
import androidx.compose.ui.Alignment
29+
import androidx.compose.ui.Modifier
30+
import androidx.compose.ui.graphics.graphicsLayer
31+
import androidx.compose.ui.tooling.preview.Preview
32+
import androidx.compose.ui.unit.dp
33+
import com.sloy.sevibus.ui.theme.SevTheme
34+
import kotlinx.coroutines.launch
35+
36+
@Composable
37+
fun AppUpdateButton(state: AppUpdateButtonState, modifier: Modifier = Modifier) {
38+
val scope = rememberCoroutineScope()
39+
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
40+
41+
val pulse = if (state is AppUpdateButtonState.Ready) {
42+
infiniteTransition.animateFloat(
43+
label = "pulse",
44+
initialValue = 1f,
45+
targetValue = 1.05f,
46+
animationSpec = infiniteRepeatable(
47+
animation = tween(durationMillis = 400, easing = FastOutSlowInEasing),
48+
repeatMode = RepeatMode.Reverse
49+
),
50+
)
51+
} else {
52+
null
53+
}
54+
55+
AnimatedVisibility(
56+
visible = state !is AppUpdateButtonState.Hidden,
57+
enter = fadeIn(),
58+
exit = fadeOut(),
59+
modifier = modifier,
60+
) {
61+
62+
Surface(
63+
onClick = {
64+
scope.launch {
65+
if (state is AppUpdateButtonState.Available) state.onClick()
66+
if (state is AppUpdateButtonState.Ready) state.onClick()
67+
}
68+
},
69+
shape = SevTheme.shapes.extraLarge,
70+
color = SevTheme.colorScheme.background,
71+
contentColor = contentColorFor(SevTheme.colorScheme.onBackground),
72+
shadowElevation = 4.dp,
73+
modifier = Modifier.graphicsLayer {
74+
pulse?.value?.let {
75+
scaleX = it
76+
scaleY = it
77+
}
78+
}
79+
) {
80+
val text = when (state) {
81+
is AppUpdateButtonState.Available -> "Actualización disponible"
82+
is AppUpdateButtonState.Downloading -> "Descargando… ${percentage(state.downloaded, state.total)}%"
83+
is AppUpdateButtonState.Ready -> "Pulsa para instalar"
84+
is AppUpdateButtonState.Hidden -> ""
85+
}
86+
87+
Row(
88+
verticalAlignment = Alignment.CenterVertically,
89+
modifier = Modifier
90+
.padding(4.dp)
91+
.padding(end = 8.dp)
92+
) {
93+
when (state) {
94+
is AppUpdateButtonState.Available -> DownloadIcon()
95+
is AppUpdateButtonState.Downloading -> ProgressIcon(progress(state.downloaded, state.total))
96+
is AppUpdateButtonState.Ready -> ReadyIcon()
97+
else -> {}
98+
}
99+
100+
Text(text, style = SevTheme.typography.bodySmallBold)
101+
102+
}
103+
}
104+
}
105+
}
106+
107+
private fun progress(completed: Long, total: Long): Float {
108+
if (total == 0L) return 0f
109+
if (completed == 0L) return 0f
110+
if (completed > total) return 1f
111+
return (completed.toFloat() / total.toFloat())
112+
}
113+
114+
private fun percentage(completed: Long, total: Long): Int {
115+
return (progress(completed, total) * 100).toInt()
116+
}
117+
118+
@Composable
119+
private fun ProgressIcon(progress: Float) {
120+
Box(
121+
Modifier
122+
.padding(end = 8.dp)
123+
.padding(4.dp)
124+
.size(20.dp)
125+
) {
126+
if (progress < 0.1) {
127+
CircularProgressIndicator(strokeWidth = 3.dp)
128+
} else {
129+
CircularProgressIndicator(progress = { progress }, strokeWidth = 3.dp)
130+
}
131+
}
132+
}
133+
134+
@Composable
135+
private fun DownloadIcon() {
136+
Box(
137+
Modifier
138+
.padding(end = 8.dp)
139+
.background(SevTheme.colorScheme.primary, CircleShape)
140+
.padding(4.dp)
141+
) {
142+
Icon(
143+
Icons.Default.InstallMobile,
144+
contentDescription = null,
145+
tint = SevTheme.colorScheme.onPrimary,
146+
modifier = Modifier.size(16.dp)
147+
)
148+
}
149+
}
150+
151+
@Composable
152+
private fun ReadyIcon() {
153+
Box(
154+
Modifier
155+
.padding(end = 8.dp)
156+
.background(SevTheme.colorScheme.primary, CircleShape)
157+
.padding(4.dp)
158+
) {
159+
Icon(
160+
Icons.Default.MobileFriendly,
161+
contentDescription = null,
162+
tint = SevTheme.colorScheme.onPrimary,
163+
modifier = Modifier.size(16.dp)
164+
)
165+
}
166+
}
167+
168+
@Preview
169+
@Composable
170+
private fun AvailablePreview() {
171+
SevTheme {
172+
AppUpdateButton(AppUpdateButtonState.Available({}), Modifier.padding(8.dp))
173+
}
174+
}
175+
176+
@Preview
177+
@Composable
178+
private fun DownloadingZeroPreview() {
179+
SevTheme {
180+
AppUpdateButton(AppUpdateButtonState.Downloading(0L, 0L), Modifier.padding(8.dp))
181+
}
182+
}
183+
184+
@Preview
185+
@Composable
186+
private fun DownloadingPreview() {
187+
SevTheme {
188+
AppUpdateButton(AppUpdateButtonState.Downloading(30L, 100L), Modifier.padding(8.dp))
189+
}
190+
}
191+
192+
@Preview
193+
@Composable
194+
private fun ReadyPreview() {
195+
SevTheme {
196+
AppUpdateButton(AppUpdateButtonState.Ready({}), Modifier.padding(8.dp))
197+
}
198+
}
199+
200+
sealed class AppUpdateButtonState {
201+
object Hidden : AppUpdateButtonState()
202+
data class Available(val onClick: suspend () -> Unit) : AppUpdateButtonState()
203+
data class Downloading(val downloaded: Long, val total: Long) : AppUpdateButtonState()
204+
data class Ready(val onClick: suspend () -> Unit) : AppUpdateButtonState()
205+
206+
}

gradle/libs.versions.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ accompanist = "0.37.3"
3030
morfly-bottomsheet = "0.1.0"
3131
coil = "2.7.0"
3232
coreSplashScreen = "1.0.1"
33+
inAppUpdate = "1.3.1"
3334

3435
[libraries]
3536
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
@@ -100,6 +101,9 @@ mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "
100101

101102
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashScreen" }
102103

104+
inAppUpdateCompose = { group = "se.warting.in-app-update", name = "in-app-update-compose", version.ref = "inAppUpdate" }
105+
inAppUpdateCompose-material = { group = "se.warting.in-app-update", name = "in-app-update-compose-mui", version.ref = "inAppUpdate" }
106+
103107
[plugins]
104108
androidApplication = { id = "com.android.application", version.ref = "agp" }
105109
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

0 commit comments

Comments
 (0)