From cc593875885e0705caf6ec7da762d6fa99d21fa6 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:52:03 +0530 Subject: [PATCH] feat: update banner layout --- .../updater/AndroidAppUpdaterPlatform.kt | 8 +- .../updater/AppUpdaterPlatform.android.kt | 4 +- .../updater/AppUpdaterPlatform.android.kt | 3 +- .../composeResources/values/strings.xml | 4 + .../commonMain/kotlin/com/nuvio/app/App.kt | 36 +- .../app/features/settings/SettingsRootPage.kt | 14 + .../app/features/settings/SettingsScreen.kt | 7 + .../nuvio/app/features/updater/AppUpdater.kt | 326 +++----------- .../app/features/updater/AppUpdaterBanner.kt | 416 ++++++++++++++++++ .../features/updater/AppUpdaterPlatform.kt | 3 +- .../updater/AppUpdaterPlatform.desktop.kt | 3 +- .../updater/AppUpdaterPlatform.ios.kt | 3 +- 12 files changed, 544 insertions(+), 283 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt index 390ce23ff..b50f2553f 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt @@ -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 = 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." } -} \ No newline at end of file +} diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt index 09009d5dc..30c7ed400 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt @@ -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 = AndroidAppUpdaterPlatform.getSupportedAbis() @@ -24,4 +26,4 @@ actual object AppUpdaterPlatform { } actual fun installDownloadedApk(path: String): Result = AndroidAppUpdaterPlatform.installDownloadedApk(path) -} \ No newline at end of file +} diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt index 6b06204ed..8efac6ee5 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt @@ -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 = emptyList() @@ -26,4 +27,4 @@ actual object AppUpdaterPlatform { actual fun installDownloadedApk(path: String): Result = Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) })) -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index d81ca03d1..c09b2c4b7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1406,6 +1406,9 @@ Empty download body Downloaded update file is missing. Downloading %1$d% + Banner test complete + Preview the banner and simulated download progress. + Test update banner Unable to start installation You're using the latest version. Enable app installs for Nuvio, then come back and continue. @@ -1413,6 +1416,7 @@ No updates found. A new version is ready to install. In-app updates are not available on this build. + No release notes were provided. Preparing download Release notes Allow installs to continue diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 49217a046..94d278da5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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 { 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, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index 1ed8b5d86..a2676246f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -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, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 6f42f03ef..9d58c39b4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt index 6cfe92451..d0b40fc8f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt new file mode 100644 index 000000000..64e521240 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt @@ -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)) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt index 0bc5d7136..8abf81256 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.updater expect object AppUpdaterPlatform { val isSupported: Boolean + val isDebugBuild: Boolean fun getSupportedAbis(): List @@ -20,4 +21,4 @@ expect object AppUpdaterPlatform { fun openUnknownSourcesSettings() fun installDownloadedApk(path: String): Result -} \ No newline at end of file +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt index 6b06204ed..8efac6ee5 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt @@ -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 = emptyList() @@ -26,4 +27,4 @@ actual object AppUpdaterPlatform { actual fun installDownloadedApk(path: String): Result = Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) })) -} \ No newline at end of file +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt index 6b06204ed..8efac6ee5 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt @@ -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 = emptyList() @@ -26,4 +27,4 @@ actual object AppUpdaterPlatform { actual fun installDownloadedApk(path: String): Result = Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) })) -} \ No newline at end of file +}