feat: update banner layout

This commit is contained in:
tapframe 2026-07-14 22:52:03 +05:30
parent 409a2e9b01
commit cc59387588
12 changed files with 544 additions and 283 deletions

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.updater
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.net.Uri
import android.os.Build
import android.provider.Settings
@ -40,6 +41,11 @@ object AndroidAppUpdaterPlatform {
fun getSupportedAbis(): List<String> = Build.SUPPORTED_ABIS?.toList().orEmpty()
fun isDebugBuild(): Boolean {
val context = appContext ?: return false
return context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
}
fun getIgnoredTag(): String? =
preferences().getString(ignoredTagKey, null)
@ -141,4 +147,4 @@ object AndroidAppUpdaterPlatform {
private fun requireContext(): Context =
requireNotNull(appContext) { "AndroidAppUpdaterPlatform.initialize must be called before use." }
}
}

View file

@ -2,6 +2,8 @@ package com.nuvio.app.features.updater
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = true
actual val isDebugBuild: Boolean
get() = AndroidAppUpdaterPlatform.isDebugBuild()
actual fun getSupportedAbis(): List<String> = AndroidAppUpdaterPlatform.getSupportedAbis()
@ -24,4 +26,4 @@ actual object AppUpdaterPlatform {
}
actual fun installDownloadedApk(path: String): Result<Unit> = AndroidAppUpdaterPlatform.installDownloadedApk(path)
}
}

View file

@ -7,6 +7,7 @@ import org.jetbrains.compose.resources.getString
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = false
actual val isDebugBuild: Boolean = false
actual fun getSupportedAbis(): List<String> = emptyList()
@ -26,4 +27,4 @@ actual object AppUpdaterPlatform {
actual fun installDownloadedApk(path: String): Result<Unit> =
Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) }))
}
}

View file

@ -1406,6 +1406,9 @@
<string name="updates_download_empty_body">Empty download body</string>
<string name="updates_download_file_missing">Downloaded update file is missing.</string>
<string name="updates_downloading_progress">Downloading %1$d%</string>
<string name="updates_debug_test_complete">Banner test complete</string>
<string name="updates_debug_test_description">Preview the banner and simulated download progress.</string>
<string name="updates_debug_test_title">Test update banner</string>
<string name="updates_install_failed">Unable to start installation</string>
<string name="updates_latest_version">You&apos;re using the latest version.</string>
<string name="updates_message_allow_installs">Enable app installs for Nuvio, then come back and continue.</string>
@ -1413,6 +1416,7 @@
<string name="updates_message_no_updates">No updates found.</string>
<string name="updates_message_ready">A new version is ready to install.</string>
<string name="updates_not_available">In-app updates are not available on this build.</string>
<string name="updates_no_release_notes">No release notes were provided.</string>
<string name="updates_preparing_download">Preparing download</string>
<string name="updates_release_notes">Release notes</string>
<string name="updates_title_allow_installs">Allow installs to continue</string>

View file

@ -215,6 +215,7 @@ import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.trakt.TraktScrobbleRepository
import com.nuvio.app.features.updater.AppUpdaterHost
import com.nuvio.app.features.updater.AppUpdaterPlatform
import com.nuvio.app.features.updater.rememberAppUpdaterController
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
@ -1781,11 +1782,15 @@ private fun MainAppContent(
selectedContinueWatchingForActions = item
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.nuvio.colors.background),
AppUpdaterHost(
controller = appUpdaterController,
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.nuvio.colors.background),
) {
Box(
modifier = Modifier
.fillMaxSize()
@ -1989,6 +1994,13 @@ private fun MainAppContent(
} else {
null
},
onTestUpdateBannerClick = if (
AppFeaturePolicy.inAppUpdaterEnabled && AppUpdaterPlatform.isDebugBuild
) {
appUpdaterController::showDebugTestUpdate
} else {
null
},
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute(collectionsTitle)) },
onFolderClick = { collectionId, folderId ->
val folderTitle = CollectionRepository.collections.value
@ -3003,6 +3015,13 @@ private fun MainAppContent(
} else {
null
},
onTestUpdateBannerClick = if (
AppFeaturePolicy.inAppUpdaterEnabled && AppUpdaterPlatform.isDebugBuild
) {
appUpdaterController::showDebugTestUpdate
} else {
null
},
)
}
entry<DownloadsSettingsRoute> { route ->
@ -3480,12 +3499,7 @@ private fun MainAppContent(
.zIndex(20f),
)
AppUpdaterHost(
controller = appUpdaterController,
modifier = Modifier
.align(Alignment.Center)
.zIndex(25f),
)
}
}
}
@ -3541,6 +3555,7 @@ private fun AppTabHost(
onSupportersContributorsSettingsClick: () -> Unit = {},
onLicensesAttributionsSettingsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onTestUpdateBannerClick: (() -> Unit)? = null,
onCollectionsSettingsClick: () -> Unit = {},
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
requestedSettingsPageName: String? = null,
@ -3608,6 +3623,7 @@ private fun AppTabHost(
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
onLicensesAttributionsClick = onLicensesAttributionsSettingsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onTestUpdateBannerClick = onTestUpdateBannerClick,
onCollectionsClick = onCollectionsSettingsClick,
)
}

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Favorite
@ -52,6 +53,8 @@ import nuvio.composeapp.generated.resources.compose_settings_root_advanced_secti
import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery
import nuvio.composeapp.generated.resources.compose_settings_page_trakt
import nuvio.composeapp.generated.resources.settings_playback_subtitle
import nuvio.composeapp.generated.resources.updates_debug_test_description
import nuvio.composeapp.generated.resources.updates_debug_test_title
import nuvio.composeapp.generated.resources.about_supporters_contributors_subtitle
import nuvio.composeapp.generated.resources.about_licenses_attributions_subtitle
import org.jetbrains.compose.resources.stringResource
@ -68,6 +71,7 @@ internal fun LazyListScope.settingsRootContent(
onSupportersContributorsClick: () -> Unit,
onLicensesAttributionsClick: () -> Unit,
onCheckForUpdatesClick: (() -> Unit)? = null,
onTestUpdateBannerClick: (() -> Unit)? = null,
onDownloadsClick: () -> Unit,
onAccountClick: () -> Unit,
onSwitchProfileClick: (() -> Unit)? = null,
@ -205,6 +209,16 @@ internal fun LazyListScope.settingsRootContent(
onClick = onCheckForUpdatesClick,
)
}
if (onTestUpdateBannerClick != null) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.updates_debug_test_title),
description = stringResource(Res.string.updates_debug_test_description),
icon = Icons.Rounded.BugReport,
isTablet = isTablet,
onClick = onTestUpdateBannerClick,
)
}
}
}
}

View file

@ -131,6 +131,7 @@ fun SettingsScreen(
onSupportersContributorsClick: () -> Unit = {},
onLicensesAttributionsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onTestUpdateBannerClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
BoxWithConstraints(
@ -410,6 +411,7 @@ fun SettingsScreen(
onSupportersContributorsClick = openSupportersContributors,
onLicensesAttributionsClick = openLicensesAttributions,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onTestUpdateBannerClick = onTestUpdateBannerClick,
onCollectionsClick = onCollectionsClick,
)
} else {
@ -473,6 +475,7 @@ fun SettingsScreen(
onSupportersContributorsClick = openSupportersContributors,
onLicensesAttributionsClick = openLicensesAttributions,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onTestUpdateBannerClick = onTestUpdateBannerClick,
onCollectionsClick = onCollectionsClick,
)
}
@ -540,6 +543,7 @@ private fun MobileSettingsScreen(
onSupportersContributorsClick: () -> Unit = {},
onLicensesAttributionsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onTestUpdateBannerClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
val saveableStateHolder = rememberSaveableStateHolder()
@ -654,6 +658,7 @@ private fun MobileSettingsScreen(
onSupportersContributorsClick = onSupportersContributorsClick,
onLicensesAttributionsClick = onLicensesAttributionsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onTestUpdateBannerClick = onTestUpdateBannerClick,
onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick,
onSwitchProfileClick = onSwitchProfile,
@ -884,6 +889,7 @@ private fun TabletSettingsScreen(
onSupportersContributorsClick: () -> Unit = {},
onLicensesAttributionsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onTestUpdateBannerClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
@ -1056,6 +1062,7 @@ private fun TabletSettingsScreen(
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
onLicensesAttributionsClick = { openInlinePage(SettingsPage.LicensesAttributions) },
onCheckForUpdatesClick = onCheckForUpdatesClick,
onTestUpdateBannerClick = onTestUpdateBannerClick,
onDownloadsClick = onDownloadsClick,
onAccountClick = { openInlinePage(SettingsPage.Account) },
onSwitchProfileClick = onSwitchProfile,

View file

@ -1,45 +1,15 @@
package com.nuvio.app.features.updater
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import com.nuvio.app.core.ui.NuvioLoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.build.AppVersionConfig
import com.nuvio.app.core.i18n.localizedByteUnit
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -52,7 +22,6 @@ import kotlinx.serialization.json.Json
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
private const val gitHubOwner = "NuvioMedia"
private const val gitHubRepo = "NuvioMobile"
@ -79,6 +48,7 @@ data class AppUpdaterUiState(
val showDialog: Boolean = false,
val showUnknownSourcesDialog: Boolean = false,
val errorMessage: String? = null,
val isDebugTest: Boolean = false,
)
@Serializable
@ -250,6 +220,7 @@ class AppUpdaterController internal constructor(
isChecking = true,
errorMessage = null,
showUnknownSourcesDialog = false,
isDebugTest = false,
)
}
@ -322,6 +293,10 @@ class AppUpdaterController internal constructor(
fun downloadUpdate() {
val update = _uiState.value.update ?: return
if (_uiState.value.isDebugTest) {
runDebugDownloadTest()
return
}
scope.launch {
_uiState.update { state ->
@ -346,7 +321,7 @@ class AppUpdaterController internal constructor(
_uiState.update { state ->
state.copy(
isDownloading = false,
downloadProgress = 1f,
downloadProgress = null,
downloadedApkPath = path,
errorMessage = null,
)
@ -395,6 +370,56 @@ class AppUpdaterController internal constructor(
AppUpdaterPlatform.openUnknownSourcesSettings()
}
}
fun showDebugTestUpdate() {
if (!AppUpdaterPlatform.isDebugBuild || !AppUpdaterPlatform.isSupported) return
_uiState.value = AppUpdaterUiState(
update = AppUpdate(
tag = "9.9.9",
title = "Nuvio 9.9.9",
notes = """
A local preview of the new update experience.
- The banner pushes the app content down.
- Download progress fills the banner with the primary accent.
- Release notes live behind the info button.
""".trimIndent(),
releaseUrl = null,
assetName = "Nuvio-debug-preview.apk",
assetUrl = "debug://update-preview",
assetSizeBytes = 185L * 1024L * 1024L,
),
isUpdateAvailable = true,
showDialog = true,
isDebugTest = true,
)
}
private fun runDebugDownloadTest() {
scope.launch {
_uiState.update { state ->
state.copy(
isDownloading = true,
downloadProgress = 0f,
errorMessage = null,
)
}
for (step in 1..100) {
delay(35)
_uiState.update { state -> state.copy(downloadProgress = step / 100f) }
}
_uiState.update { state ->
state.copy(
isDownloading = false,
isUpdateAvailable = false,
downloadProgress = 1f,
)
}
}
}
}
@Composable
@ -403,240 +428,7 @@ fun rememberAppUpdaterController(): AppUpdaterController {
return remember(scope) { AppUpdaterController(scope) }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppUpdaterHost(
controller: AppUpdaterController,
modifier: Modifier = Modifier,
) {
if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) {
return
}
val state by controller.uiState.collectAsStateWithLifecycle()
LaunchedEffect(controller) {
controller.ensureAutoCheckStarted()
}
if (!state.showDialog) return
val showPrimaryAction =
state.showUnknownSourcesDialog || state.isDownloading || state.downloadedApkPath != null || state.isUpdateAvailable
BasicAlertDialog(
onDismissRequest = {
if (!state.isDownloading) {
controller.dismissDialog()
}
},
) {
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shadowElevation = 16.dp,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = when {
state.showUnknownSourcesDialog -> stringResource(Res.string.updates_title_allow_installs)
state.isUpdateAvailable -> state.update?.title ?: stringResource(Res.string.updates_title_available)
else -> stringResource(Res.string.updates_title_status)
},
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = when {
state.showUnknownSourcesDialog -> stringResource(Res.string.updates_message_allow_installs)
state.isDownloading -> stringResource(Res.string.updates_message_downloading)
state.isUpdateAvailable -> stringResource(Res.string.updates_message_ready)
else -> stringResource(Res.string.updates_message_no_updates)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
state.errorMessage?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
state.update?.let { update ->
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (state.isChecking) {
NuvioLoadingIndicator(
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(10.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = update.tag,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
val assetLine = update.assetSizeBytes?.let(::formatFileSize)?.let { size ->
stringResource(Res.string.updates_asset_line, size, update.assetName)
} ?: update.assetName
Text(
text = assetLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (state.isDownloading || state.downloadProgress != null) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
LinearProgressIndicator(
progress = { (state.downloadProgress ?: 0f).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
)
Text(
text = if (state.downloadProgress != null) {
stringResource(
Res.string.updates_downloading_progress,
((state.downloadProgress ?: 0f) * 100).toInt().coerceIn(0, 100),
)
} else {
stringResource(Res.string.updates_preparing_download)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (update.notes.isNotBlank()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(Res.string.updates_release_notes),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Text(
text = update.notes,
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(14.dp)
.verticalScroll(rememberScrollState()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (showPrimaryAction) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
when {
state.showUnknownSourcesDialog -> controller.resumeInstallation()
state.downloadedApkPath != null -> controller.installDownloadedUpdate()
else -> controller.downloadUpdate()
}
},
enabled = if (state.showUnknownSourcesDialog || state.downloadedApkPath != null) {
true
} else {
!state.isChecking && !state.isDownloading && state.isUpdateAvailable
},
) {
Text(
when {
state.showUnknownSourcesDialog -> stringResource(Res.string.action_continue)
state.downloadedApkPath != null -> stringResource(Res.string.action_install)
state.isDownloading -> stringResource(Res.string.updates_message_downloading)
else -> stringResource(Res.string.action_update)
},
)
}
}
if (state.isUpdateAvailable && !state.isDownloading && !state.showUnknownSourcesDialog) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = controller::ignoreThisVersion,
) {
Text(stringResource(Res.string.action_ignore))
}
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = controller::dismissDialog,
enabled = !state.isDownloading,
) {
Text(
if (state.isDownloading) {
stringResource(Res.string.updates_message_downloading)
} else {
stringResource(Res.string.action_later)
},
)
}
}
} else {
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = controller::dismissDialog,
enabled = !state.isDownloading,
) {
Text(
if (state.isDownloading) {
stringResource(Res.string.updates_message_downloading)
} else {
stringResource(Res.string.action_later)
},
)
}
}
}
}
}
}
}
private fun formatFileSize(sizeBytes: Long): String {
internal fun formatFileSize(sizeBytes: Long): String {
if (sizeBytes <= 0L) return "0 ${localizedByteUnit("B")}"
val units = listOf("B", "KB", "MB", "GB")
var value = sizeBytes.toDouble()

View file

@ -0,0 +1,416 @@
package com.nuvio.app.features.updater
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.ui.nuvio
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_close
import nuvio.composeapp.generated.resources.action_continue
import nuvio.composeapp.generated.resources.action_install
import nuvio.composeapp.generated.resources.action_later
import nuvio.composeapp.generated.resources.action_retry
import nuvio.composeapp.generated.resources.action_update
import nuvio.composeapp.generated.resources.updates_debug_test_complete
import nuvio.composeapp.generated.resources.updates_downloading_progress
import nuvio.composeapp.generated.resources.updates_message_allow_installs
import nuvio.composeapp.generated.resources.updates_message_ready
import nuvio.composeapp.generated.resources.updates_no_release_notes
import nuvio.composeapp.generated.resources.updates_preparing_download
import nuvio.composeapp.generated.resources.updates_release_notes
import nuvio.composeapp.generated.resources.updates_title_allow_installs
import nuvio.composeapp.generated.resources.updates_title_available
import org.jetbrains.compose.resources.stringResource
@Composable
fun AppUpdaterHost(
controller: AppUpdaterController,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) {
content()
return
}
val state by controller.uiState.collectAsStateWithLifecycle()
var showReleaseNotes by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(controller) {
controller.ensureAutoCheckStarted()
}
LaunchedEffect(state.update?.tag) {
showReleaseNotes = false
}
val update = state.update
val showBanner = state.showDialog && update != null
Column(modifier = modifier) {
AnimatedVisibility(
visible = showBanner,
enter = expandVertically(
expandFrom = Alignment.Top,
animationSpec = tween(durationMillis = 300),
) + fadeIn(animationSpec = tween(durationMillis = 180)),
exit = shrinkVertically(
shrinkTowards = Alignment.Top,
animationSpec = tween(durationMillis = 240),
) + fadeOut(animationSpec = tween(durationMillis = 150)),
) {
update?.let { availableUpdate ->
AppUpdateBanner(
state = state,
update = availableUpdate,
onDownload = controller::downloadUpdate,
onInstall = controller::installDownloadedUpdate,
onShowReleaseNotes = { showReleaseNotes = true },
onDismiss = controller::dismissDialog,
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.then(
if (showBanner) {
Modifier.consumeWindowInsets(WindowInsets.statusBars)
} else {
Modifier
},
),
) {
content()
}
}
if (showReleaseNotes && update != null) {
ReleaseNotesDialog(
update = update,
onDismiss = { showReleaseNotes = false },
)
}
if (state.showUnknownSourcesDialog) {
UnknownSourcesDialog(
onContinue = controller::resumeInstallation,
onDismiss = controller::dismissDialog,
)
}
}
@Composable
private fun AppUpdateBanner(
state: AppUpdaterUiState,
update: AppUpdate,
onDownload: () -> Unit,
onInstall: () -> Unit,
onShowReleaseNotes: () -> Unit,
onDismiss: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
val targetProgress = when {
state.isDownloading -> state.downloadProgress ?: 0f
state.isDebugTest && !state.isUpdateAvailable -> 1f
else -> 0f
}.coerceIn(0f, 1f)
val progress by animateFloatAsState(
targetValue = targetProgress,
animationSpec = tween(durationMillis = 180),
label = "updateBannerProgress",
)
val containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
val progressColor = MaterialTheme.colorScheme.primary
val dividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f)
val debugTestComplete = state.isDebugTest && !state.isDownloading && !state.isUpdateAvailable
val subtitle = when {
state.errorMessage != null -> state.errorMessage
state.isDownloading && state.downloadProgress != null -> stringResource(
Res.string.updates_downloading_progress,
(state.downloadProgress * 100).toInt().coerceIn(0, 100),
)
state.isDownloading -> stringResource(Res.string.updates_preparing_download)
debugTestComplete -> stringResource(Res.string.updates_debug_test_complete)
state.downloadedApkPath != null -> stringResource(Res.string.updates_message_ready)
else -> stringResource(Res.string.updates_title_available)
}
val updateLabel = listOfNotNull(
update.tag,
update.assetSizeBytes?.let(::formatFileSize),
).joinToString(separator = "")
Box(
modifier = Modifier
.fillMaxWidth()
.drawBehind {
drawRect(containerColor)
if (progress > 0f) {
drawRect(
color = progressColor,
size = Size(width = size.width * progress, height = size.height),
)
}
drawRect(
color = dividerColor,
topLeft = Offset(0f, size.height - 1.dp.toPx()),
size = Size(width = size.width, height = 1.dp.toPx()),
)
}
.windowInsetsPadding(WindowInsets.statusBars),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 68.dp)
.padding(horizontal = tokens.spacing.screenHorizontal, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = if (debugTestComplete) Icons.Rounded.CheckCircle else Icons.Rounded.CloudDownload,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(24.dp),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = updateLabel,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = when {
state.errorMessage != null -> MaterialTheme.colorScheme.error
state.isDownloading -> MaterialTheme.colorScheme.onPrimary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
maxLines = 2,
)
}
IconButton(
onClick = onShowReleaseNotes,
enabled = update.notes.isNotBlank(),
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Rounded.Info,
contentDescription = stringResource(Res.string.updates_release_notes),
tint = MaterialTheme.colorScheme.onSurface,
)
}
if (!state.isDownloading && !debugTestComplete) {
Button(
onClick = if (state.downloadedApkPath != null) onInstall else onDownload,
enabled = state.downloadedApkPath != null || state.isUpdateAvailable,
modifier = Modifier.heightIn(min = 40.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
if (state.downloadedApkPath != null) {
stringResource(Res.string.action_install)
} else if (state.errorMessage != null) {
stringResource(Res.string.action_retry)
} else {
stringResource(Res.string.action_update)
},
)
}
}
if (!state.isDownloading) {
IconButton(
onClick = onDismiss,
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = stringResource(Res.string.action_close),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReleaseNotesDialog(
update: AppUpdate,
onDismiss: () -> Unit,
) {
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier
.widthIn(max = 560.dp)
.fillMaxWidth()
.padding(horizontal = 20.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shadowElevation = 16.dp,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = stringResource(Res.string.updates_release_notes),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = update.title,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = stringResource(Res.string.action_close),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Text(
text = update.notes.ifBlank { stringResource(Res.string.updates_no_release_notes) },
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 420.dp)
.verticalScroll(rememberScrollState()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UnknownSourcesDialog(
onContinue: () -> Unit,
onDismiss: () -> Unit,
) {
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier
.widthIn(max = 480.dp)
.fillMaxWidth()
.padding(horizontal = 20.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shadowElevation = 16.dp,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = stringResource(Res.string.updates_title_allow_installs),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(Res.string.updates_message_allow_installs),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.action_later))
}
Button(onClick = onContinue) {
Text(stringResource(Res.string.action_continue))
}
}
}
}
}
}

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.updater
expect object AppUpdaterPlatform {
val isSupported: Boolean
val isDebugBuild: Boolean
fun getSupportedAbis(): List<String>
@ -20,4 +21,4 @@ expect object AppUpdaterPlatform {
fun openUnknownSourcesSettings()
fun installDownloadedApk(path: String): Result<Unit>
}
}

View file

@ -7,6 +7,7 @@ import org.jetbrains.compose.resources.getString
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = false
actual val isDebugBuild: Boolean = false
actual fun getSupportedAbis(): List<String> = emptyList()
@ -26,4 +27,4 @@ actual object AppUpdaterPlatform {
actual fun installDownloadedApk(path: String): Result<Unit> =
Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) }))
}
}

View file

@ -7,6 +7,7 @@ import org.jetbrains.compose.resources.getString
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = false
actual val isDebugBuild: Boolean = false
actual fun getSupportedAbis(): List<String> = emptyList()
@ -26,4 +27,4 @@ actual object AppUpdaterPlatform {
actual fun installDownloadedApk(path: String): Result<Unit> =
Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) }))
}
}