diff --git a/app/src/main/java/eu/kanade/presentation/components/Banners.kt b/app/src/main/java/eu/kanade/presentation/components/Banners.kt index 465e3ad5a4..14525e137b 100644 --- a/app/src/main/java/eu/kanade/presentation/components/Banners.kt +++ b/app/src/main/java/eu/kanade/presentation/components/Banners.kt @@ -1,28 +1,45 @@ package eu.kanade.presentation.components import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastMap +import androidx.compose.ui.util.fastMaxBy import eu.kanade.tachiyomi.R val DownloadedOnlyBannerBackgroundColor @Composable get() = MaterialTheme.colorScheme.tertiary val IncognitoModeBannerBackgroundColor @Composable get() = MaterialTheme.colorScheme.primary +val IndexingBannerBackgroundColor + @Composable get() = MaterialTheme.colorScheme.secondary @Composable fun WarningBanner( @@ -45,23 +62,64 @@ fun WarningBanner( fun AppStateBanners( downloadedOnlyMode: Boolean, incognitoMode: Boolean, + indexing: Boolean, modifier: Modifier = Modifier, ) { - val insets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - Column(modifier = modifier) { - if (downloadedOnlyMode) { - DownloadedOnlyModeBanner( - modifier = Modifier.windowInsetsPadding(insets), - ) - } - if (incognitoMode) { - IncognitoModeBanner( - modifier = if (!downloadedOnlyMode) { - Modifier.windowInsetsPadding(insets) - } else { - Modifier - }, - ) + val density = LocalDensity.current + val mainInsets = WindowInsets.statusBars + val mainInsetsTop = mainInsets.getTop(density) + SubcomposeLayout(modifier = modifier) { constraints -> + val indexingPlaceable = subcompose(0) { + AnimatedVisibility( + visible = indexing, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + IndexingDownloadBanner( + modifier = Modifier.windowInsetsPadding(mainInsets), + ) + } + }.fastMap { it.measure(constraints) } + val indexingHeight = indexingPlaceable.fastMaxBy { it.height }?.height ?: 0 + + val downloadedOnlyPlaceable = subcompose(1) { + AnimatedVisibility( + visible = downloadedOnlyMode, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + val top = (mainInsetsTop - indexingHeight).coerceAtLeast(0) + DownloadedOnlyModeBanner( + modifier = Modifier.windowInsetsPadding(WindowInsets(top = top)), + ) + } + }.fastMap { it.measure(constraints) } + val downloadedOnlyHeight = downloadedOnlyPlaceable.fastMaxBy { it.height }?.height ?: 0 + + val incognitoPlaceable = subcompose(2) { + AnimatedVisibility( + visible = incognitoMode, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + val top = (mainInsetsTop - indexingHeight - downloadedOnlyHeight).coerceAtLeast(0) + IncognitoModeBanner( + modifier = Modifier.windowInsetsPadding(WindowInsets(top = top)), + ) + } + }.fastMap { it.measure(constraints) } + val incognitoHeight = incognitoPlaceable.fastMaxBy { it.height }?.height ?: 0 + + layout(constraints.maxWidth, indexingHeight + downloadedOnlyHeight + incognitoHeight) { + indexingPlaceable.fastForEach { + it.place(0, 0) + } + downloadedOnlyPlaceable.fastForEach { + it.place(0, indexingHeight) + } + incognitoPlaceable.fastForEach { + it.place(0, indexingHeight + downloadedOnlyHeight) + } } } } @@ -95,3 +153,35 @@ private fun IncognitoModeBanner(modifier: Modifier = Modifier) { style = MaterialTheme.typography.labelMedium, ) } + +@Composable +private fun IndexingDownloadBanner(modifier: Modifier = Modifier) { + val density = LocalDensity.current + Row( + modifier = Modifier + .background(color = IndexingBannerBackgroundColor) + .fillMaxWidth() + .padding(8.dp) + .then(modifier), + horizontalArrangement = Arrangement.Center, + ) { + var textHeight by remember { mutableStateOf(0.dp) } + CircularProgressIndicator( + modifier = Modifier.requiredSize(textHeight), + color = MaterialTheme.colorScheme.onSecondary, + strokeWidth = textHeight / 8, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.download_notifier_cache_renewal), + color = MaterialTheme.colorScheme.onSecondary, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMedium, + onTextLayout = { + with(density) { + textHeight = it.size.height.toDp() + } + }, + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index a457c069cb..4f90943c65 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt @@ -20,11 +20,14 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withTimeout import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -53,8 +56,6 @@ class DownloadCache( .onStart { emit(Unit) } .shareIn(scope, SharingStarted.Eagerly, 1) - private val notifier by lazy { DownloadNotifier(context) } - /** * The interval after which this cache should be invalidated. 1 hour shouldn't cause major * issues, as the cache is only used for UI feedback. @@ -66,6 +67,10 @@ class DownloadCache( */ private var lastRenew = 0L private var renewalJob: Job? = null + val isRenewing = changes + .map { renewalJob?.isActive ?: false } + .distinctUntilChanged() + .stateIn(scope, SharingStarted.WhileSubscribed(), false) private var rootDownloadsDir = RootDirectory(getDirectoryFromPreference()) @@ -260,8 +265,6 @@ class DownloadCache( } renewalJob = scope.launchIO { - notifier.onCacheProgress() - var sources = getSources() // Try to wait until extensions and sources have loaded @@ -320,7 +323,6 @@ class DownloadCache( lastRenew = System.currentTimeMillis() notifyChanges() } - renewalJob?.invokeOnCompletion { notifier.dismissCacheProgress() } } private fun getSources(): List { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt index d8b3af8d41..11f655c36b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt @@ -39,17 +39,6 @@ internal class DownloadNotifier(private val context: Context) { } } - private val cacheNotificationBuilder by lazy { - context.notificationBuilder(Notifications.CHANNEL_DOWNLOADER_CACHE) { - setSmallIcon(R.drawable.ic_tachi) - setContentTitle(context.getString(R.string.download_notifier_cache_renewal)) - setProgress(100, 100, true) - setOngoing(true) - setAutoCancel(false) - setOnlyAlertOnce(true) - } - } - /** * Status of download. Used for correct notification icon. */ @@ -223,14 +212,4 @@ internal class DownloadNotifier(private val context: Context) { errorThrown = true isDownloading = false } - - fun onCacheProgress() { - with(cacheNotificationBuilder) { - show(Notifications.ID_DOWNLOAD_CACHE) - } - } - - fun dismissCacheProgress() { - context.notificationManager.cancel(Notifications.ID_DOWNLOAD_CACHE) - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt index 41a879e5ea..ab0e75e141 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt @@ -40,8 +40,6 @@ object Notifications { const val ID_DOWNLOAD_CHAPTER_PROGRESS = -201 const val CHANNEL_DOWNLOADER_ERROR = "downloader_error_channel" const val ID_DOWNLOAD_CHAPTER_ERROR = -202 - const val CHANNEL_DOWNLOADER_CACHE = "downloader_cache_renewal" - const val ID_DOWNLOAD_CACHE = -204 /** * Notification channel and ids used by the library updater. @@ -91,6 +89,7 @@ object Notifications { "library_channel", "library_progress_channel", "updates_ext_channel", + "downloader_cache_renewal", ) /** @@ -155,12 +154,6 @@ object Notifications { setGroup(GROUP_DOWNLOADER) setShowBadge(false) }, - buildNotificationChannel(CHANNEL_DOWNLOADER_CACHE, IMPORTANCE_LOW) { - setName(context.getString(R.string.channel_downloader_cache)) - setGroup(GROUP_DOWNLOADER) - setShowBadge(false) - setSound(null, null) - }, buildNotificationChannel(CHANNEL_BACKUP_RESTORE_PROGRESS, IMPORTANCE_LOW) { setName(context.getString(R.string.channel_progress)) setGroup(GROUP_BACKUP_RESTORE) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 02a5c57003..9f57cffc46 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -12,10 +12,13 @@ import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -23,6 +26,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -54,6 +58,8 @@ import eu.kanade.domain.ui.UiPreferences import eu.kanade.presentation.components.AppStateBanners import eu.kanade.presentation.components.DownloadedOnlyBannerBackgroundColor import eu.kanade.presentation.components.IncognitoModeBannerBackgroundColor +import eu.kanade.presentation.components.IndexingBannerBackgroundColor +import eu.kanade.presentation.components.Scaffold import eu.kanade.presentation.util.AssistContentScreen import eu.kanade.presentation.util.DefaultNavigatorScreenTransition import eu.kanade.presentation.util.collectAsState @@ -61,6 +67,7 @@ import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.Migrations import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.ChapterCache +import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.updater.AppUpdateChecker import eu.kanade.tachiyomi.data.updater.AppUpdateResult @@ -101,6 +108,7 @@ class MainActivity : BaseActivity() { private val preferences: BasePreferences by injectLazy() private val chapterCache: ChapterCache by injectLazy() + private val downloadCache: DownloadCache by injectLazy() // To be checked by splash screen. If true then splash screen will be removed. var ready = false @@ -153,94 +161,102 @@ class MainActivity : BaseActivity() { setComposeContent { val incognito by preferences.incognitoMode().collectAsState() val downloadOnly by preferences.downloadedOnly().collectAsState() - Column { - AppStateBanners( - downloadedOnlyMode = downloadOnly, - incognitoMode = incognito, + val indexing by downloadCache.isRenewing.collectAsState() + + // Set statusbar color considering the top app state banner + val systemUiController = rememberSystemUiController() + val isSystemInDarkTheme = isSystemInDarkTheme() + val statusBarBackgroundColor = when { + indexing -> IndexingBannerBackgroundColor + downloadOnly -> DownloadedOnlyBannerBackgroundColor + incognito -> IncognitoModeBannerBackgroundColor + else -> MaterialTheme.colorScheme.surface + } + LaunchedEffect(systemUiController, statusBarBackgroundColor) { + systemUiController.setStatusBarColor( + color = ComposeColor.Transparent, + darkIcons = statusBarBackgroundColor.luminance() > 0.5, + transformColorForLightContent = { ComposeColor.Black }, ) + } - // Set statusbar color - val systemUiController = rememberSystemUiController() - val isSystemInDarkTheme = isSystemInDarkTheme() - val statusBarBackgroundColor = when { - downloadOnly -> DownloadedOnlyBannerBackgroundColor - incognito -> IncognitoModeBannerBackgroundColor - else -> MaterialTheme.colorScheme.background - } - LaunchedEffect(systemUiController, statusBarBackgroundColor) { - systemUiController.setStatusBarColor( - color = ComposeColor.Transparent, - darkIcons = statusBarBackgroundColor.luminance() > 0.5, - transformColorForLightContent = { ComposeColor.Black }, - ) - } - - // Set navigation bar color - val context = LocalContext.current - val navbarScrimColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - LaunchedEffect(systemUiController, isSystemInDarkTheme, navbarScrimColor) { - systemUiController.setNavigationBarColor( - color = if (context.isNavigationBarNeedsScrim()) { - navbarScrimColor.copy(alpha = 0.7f) - } else { - ComposeColor.Transparent - }, - darkIcons = !isSystemInDarkTheme, - navigationBarContrastEnforced = false, - transformColorForLightContent = { ComposeColor.Black }, - ) - } - - Navigator( - screen = HomeScreen, - disposeBehavior = NavigatorDisposeBehavior(disposeNestedNavigators = false, disposeSteps = true), - ) { navigator -> - if (navigator.size == 1) { - ConfirmExit() - } - - LaunchedEffect(navigator) { - this@MainActivity.navigator = navigator - - if (savedInstanceState == null) { - // Set start screen - handleIntentAction(intent) - - // Reset Incognito Mode on relaunch - preferences.incognitoMode().set(false) - } - } - - // Consume insets already used by app state banners - val boxModifier = if (incognito || downloadOnly) { - Modifier.consumeWindowInsets(WindowInsets.statusBars) + // Set navigation bar color + val context = LocalContext.current + val navbarScrimColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + LaunchedEffect(systemUiController, isSystemInDarkTheme, navbarScrimColor) { + systemUiController.setNavigationBarColor( + color = if (context.isNavigationBarNeedsScrim()) { + navbarScrimColor.copy(alpha = 0.7f) } else { - Modifier + ComposeColor.Transparent + }, + darkIcons = !isSystemInDarkTheme, + navigationBarContrastEnforced = false, + transformColorForLightContent = { ComposeColor.Black }, + ) + } + + Navigator( + screen = HomeScreen, + disposeBehavior = NavigatorDisposeBehavior(disposeNestedNavigators = false, disposeSteps = true), + ) { navigator -> + if (navigator.size == 1) { + ConfirmExit() + } + + LaunchedEffect(navigator) { + this@MainActivity.navigator = navigator + + if (savedInstanceState == null) { + // Set start screen + handleIntentAction(intent) + + // Reset Incognito Mode on relaunch + preferences.incognitoMode().set(false) } - Box(modifier = boxModifier) { + } + + val scaffoldInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal) + Scaffold( + topBar = { + AppStateBanners( + downloadedOnlyMode = downloadOnly, + incognitoMode = incognito, + indexing = indexing, + modifier = Modifier.windowInsetsPadding(scaffoldInsets), + ) + }, + contentWindowInsets = scaffoldInsets, + ) { contentPadding -> + // Consume insets already used by app state banners + Box( + modifier = Modifier + .padding(contentPadding) + .consumeWindowInsets(contentPadding), + ) { // Shows current screen DefaultNavigatorScreenTransition(navigator = navigator) } + } - // Pop source-related screens when incognito mode is turned off - LaunchedEffect(Unit) { - preferences.incognitoMode().changes() - .drop(1) - .onEach { - if (!it) { - val currentScreen = navigator.lastItem - if (currentScreen is BrowseSourceScreen || - (currentScreen is MangaScreen && currentScreen.fromSource) - ) { - navigator.popUntilRoot() - } + // Pop source-related screens when incognito mode is turned off + LaunchedEffect(Unit) { + preferences.incognitoMode().changes() + .drop(1) + .onEach { + if (!it) { + val currentScreen = navigator.lastItem + if (currentScreen is BrowseSourceScreen || + (currentScreen is MangaScreen && currentScreen.fromSource) + ) { + navigator.popUntilRoot() } } - .launchIn(this) - } - - CheckForUpdate() + } + .launchIn(this) } + + CheckForUpdate() } var showChangelog by remember { mutableStateOf(didMigration && !BuildConfig.DEBUG) }