From 996fd7f949bee30cccf84feb37ffe08a2d0bf275 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 21 May 2026 21:29:39 +0530 Subject: [PATCH] ref(cloud): ui adjustments --- .../app/features/debrid/DebridProviderApis.kt | 58 +++++++++++-------- .../com/nuvio/app/features/home/HomeScreen.kt | 10 +++- .../home/components/HomeSkeletonLoading.kt | 25 ++++---- .../app/features/library/LibraryScreen.kt | 52 ++++++++--------- .../nuvio/app/features/search/SearchScreen.kt | 20 +++++-- .../features/settings/DebridSettingsPage.kt | 12 ++++ .../features/debrid/TorboxDeviceAuthTest.kt | 50 ++++++++++++++++ 7 files changed, 160 insertions(+), 67 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/TorboxDeviceAuthTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt index 9ebe9616..295179d8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt @@ -84,30 +84,7 @@ private class TorboxDebridProviderApi( val normalized = deviceCode.trim() if (normalized.isBlank()) return DebridDeviceAuthorizationTokenResult.Failed(null) val response = TorboxApiClient.redeemDeviceAuthorization(deviceCode = normalized) - val envelope = response.body - val accessToken = envelope - ?.takeIf { response.isSuccessful && it.success != false } - ?.data - ?.accessToken - ?.takeIf { it.isNotBlank() } - if (accessToken != null) { - return DebridDeviceAuthorizationTokenResult.Authorized(accessToken) - } - val message = listOfNotNull(envelope?.error, envelope?.detail, response.rawBody) - .joinToString(" ") - .lowercase() - return when { - message.contains("pending") || message.contains("not authorized") -> - DebridDeviceAuthorizationTokenResult.Pending - message.contains("expired") -> - DebridDeviceAuthorizationTokenResult.Expired - response.status == 404 || response.status == 409 || response.status == 425 -> - DebridDeviceAuthorizationTokenResult.Pending - response.status == 410 -> - DebridDeviceAuthorizationTokenResult.Expired - else -> - DebridDeviceAuthorizationTokenResult.Failed(envelope?.detail ?: envelope?.error) - } + return torboxDeviceAuthorizationTokenResult(response) } override suspend fun resolveClientStream( @@ -320,6 +297,39 @@ internal fun premiumizeDeviceAuthorizationFromResponse( ) } +internal fun torboxDeviceAuthorizationTokenResult( + response: DebridApiResponse>, +): DebridDeviceAuthorizationTokenResult { + val envelope = response.body + val accessToken = envelope + ?.takeIf { response.isSuccessful && it.success != false } + ?.data + ?.accessToken + ?.takeIf { it.isNotBlank() } + if (accessToken != null) { + return DebridDeviceAuthorizationTokenResult.Authorized(accessToken) + } + val message = listOfNotNull(envelope?.error, envelope?.detail, response.rawBody) + .joinToString(" ") + .lowercase() + return when { + message.contains("pending") || + message.contains("not authorized") || + message.contains("not been used") || + message.contains("not used yet") || + message.contains("scan the code") -> + DebridDeviceAuthorizationTokenResult.Pending + message.contains("expired") -> + DebridDeviceAuthorizationTokenResult.Expired + response.status == 404 || response.status == 409 || response.status == 425 -> + DebridDeviceAuthorizationTokenResult.Pending + response.status == 410 -> + DebridDeviceAuthorizationTokenResult.Expired + else -> + DebridDeviceAuthorizationTokenResult.Failed(envelope?.detail ?: envelope?.error) + } +} + internal fun premiumizeDeviceAuthorizationTokenResult( response: DebridApiResponse, ): DebridDeviceAuthorizationTokenResult { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 3bf4715b..a5da0fdc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -100,7 +100,10 @@ fun HomeScreen( val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle() - val homeSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() + val homeSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() val homeListState = rememberLazyListState() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() @@ -612,7 +615,10 @@ fun HomeScreen( } } items(3) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = 16.dp), + showHeaderAccent = !homeSettingsUiState.hideCatalogUnderline, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt index 3609fd00..d95b26bc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt @@ -181,7 +181,10 @@ fun HomeSkeletonHero( } @Composable -fun HomeSkeletonRow(modifier: Modifier = Modifier) { +fun HomeSkeletonRow( + modifier: Modifier = Modifier, + showHeaderAccent: Boolean = true, +) { val brush = rememberHomeSkeletonBrush() val posterCardStyle = rememberPosterCardStyleUiState() val skeletonWidth = if (posterCardStyle.catalogLandscapeModeEnabled) { @@ -207,15 +210,17 @@ fun HomeSkeletonRow(modifier: Modifier = Modifier) { .clip(RoundedCornerShape(6.dp)) .background(brush), ) - // Accent bar - Box( - modifier = Modifier - .width(60.dp) - .height(4.dp) - .clip(RoundedCornerShape(999.dp)) - .background(brush), - ) - Spacer(modifier = Modifier.height(2.dp)) + if (showHeaderAccent) { + // Accent bar + Box( + modifier = Modifier + .width(60.dp) + .height(4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(brush), + ) + Spacer(modifier = Modifier.height(2.dp)) + } // Poster row Row( horizontalArrangement = Arrangement.spacedBy(10.dp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 3f196705..af8b6410 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -72,6 +72,7 @@ import com.nuvio.app.features.cloud.CloudLibraryItemType import com.nuvio.app.features.cloud.CloudLibraryRepository import com.nuvio.app.features.cloud.CloudLibraryUiState import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow @@ -107,6 +108,10 @@ fun LibraryScreen( WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() + val homeCatalogSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } var sourceModeName by rememberSaveable { mutableStateOf(LibraryViewMode.Saved.name) } @@ -230,7 +235,10 @@ fun LibraryScreen( when { !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> { items(3) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = 16.dp), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) } } @@ -288,6 +296,7 @@ fun LibraryScreen( librarySections( sections = uiState.sections, watchedKeys = watchedUiState.watchedKeys, + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, onPosterClick = onPosterClick, onSectionViewAllClick = onSectionViewAllClick, onPosterLongClick = onPosterLongClick, @@ -423,7 +432,7 @@ private fun LazyListScope.cloudLibrarySkeletonItems() { modifier = Modifier.padding(horizontal = 16.dp), ) } - items(4) { + items(3) { CloudLibrarySkeletonRow() } } @@ -856,24 +865,17 @@ private fun CloudLibrarySkeletonToolbar( modifier: Modifier = Modifier, ) { val brush = rememberCloudLibrarySkeletonBrush() - Column( + Row( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - CloudSkeletonBlock(brush = brush, width = 92.dp, height = 34.dp, cornerRadius = 12.dp) - CloudSkeletonBlock(brush = brush, width = 78.dp, height = 34.dp, cornerRadius = 12.dp) - CloudSkeletonBlock( - brush = brush, - modifier = Modifier.weight(1f), - height = 34.dp, - cornerRadius = 12.dp, - ) - CloudSkeletonBlock(brush = brush, width = 40.dp, height = 40.dp, cornerRadius = 20.dp) + CloudSkeletonBlock(brush = brush, width = 112.dp, height = 36.dp, cornerRadius = 12.dp) + CloudSkeletonBlock(brush = brush, width = 92.dp, height = 36.dp, cornerRadius = 12.dp) } } } @@ -903,35 +905,29 @@ private fun CloudLibrarySkeletonRow( ) { Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(7.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { CloudSkeletonBlock( brush = brush, modifier = Modifier.fillMaxWidth(0.74f), - height = 17.dp, + height = 18.dp, cornerRadius = 6.dp, ) CloudSkeletonBlock( brush = brush, - modifier = Modifier.fillMaxWidth(), - height = 12.dp, + modifier = Modifier.fillMaxWidth(0.9f), + height = 14.dp, cornerRadius = 6.dp, ) CloudSkeletonBlock( brush = brush, - modifier = Modifier.fillMaxWidth(0.58f), + modifier = Modifier.fillMaxWidth(0.52f), height = 12.dp, cornerRadius = 6.dp, ) } - CloudSkeletonBlock(brush = brush, width = 32.dp, height = 32.dp, cornerRadius = 16.dp) + CloudSkeletonBlock(brush = brush, width = 48.dp, height = 48.dp, cornerRadius = 24.dp) } - CloudSkeletonBlock( - brush = brush, - modifier = Modifier.fillMaxWidth(0.44f), - height = 4.dp, - cornerRadius = 999.dp, - ) } } } @@ -987,6 +983,7 @@ private enum class LibraryViewMode { private fun LazyListScope.librarySections( sections: List, watchedKeys: Set, + showHeaderAccent: Boolean, onPosterClick: ((LibraryItem) -> Unit)?, onSectionViewAllClick: ((LibrarySection) -> Unit)?, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, @@ -1001,6 +998,7 @@ private fun LazyListScope.librarySections( entries = previewItems, headerHorizontalPadding = 16.dp, rowContentPadding = PaddingValues(horizontal = 16.dp), + showHeaderAccent = showHeaderAccent, onViewAllClick = if (section.items.size > LIBRARY_SECTION_PREVIEW_LIMIT) { onSectionViewAllClick?.let { { it(section) } } } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index adcaa7e6..8aba024f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -104,7 +104,10 @@ fun SearchScreen( val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val uiState by SearchRepository.uiState.collectAsStateWithLifecycle() val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle() - val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() + val homeCatalogSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() @@ -305,13 +308,19 @@ fun SearchScreen( when { isWaitingForSearch -> { items(2) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = homeSectionPadding), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) } } uiState.isLoading && uiState.sections.isEmpty() -> { items(2) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = homeSectionPadding), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) } } @@ -351,7 +360,10 @@ fun SearchScreen( } if (uiState.isLoading) { item(key = "search_loading_more") { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = homeSectionPadding), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index bef71870..3ea86ce7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -123,12 +123,15 @@ import nuvio.composeapp.generated.resources.settings_debrid_section_providers import nuvio.composeapp.generated.resources.settings_debrid_section_title import org.jetbrains.compose.resources.stringResource +private const val CLOUD_SERVICES_FAQ_URL = "https://nuvioapp.space/faq#common-cloud-library-and-cloud-services" + internal fun LazyListScope.debridSettingsContent( isTablet: Boolean, settings: DebridSettings, ) { item { var showResolverProviderDialog by rememberSaveable { mutableStateOf(false) } + val uriHandler = LocalUriHandler.current val resolverProviders = settings.resolverServices.map { it.provider } val activeResolverProvider = settings.activeResolverCredential?.provider SettingsSection( @@ -141,6 +144,15 @@ internal fun LazyListScope.debridSettingsContent( text = stringResource(Res.string.settings_debrid_experimental_notice), ) SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Learn more", + description = "Cloud Library, connected accounts, and playable-link preparation.", + value = "Open", + enabled = true, + onClick = { runCatching { uriHandler.openUri(CLOUD_SERVICES_FAQ_URL) } }, + ) + SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( title = stringResource(Res.string.settings_debrid_cloud_library), description = stringResource(Res.string.settings_debrid_cloud_library_description), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/TorboxDeviceAuthTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/TorboxDeviceAuthTest.kt new file mode 100644 index 00000000..594a985a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/TorboxDeviceAuthTest.kt @@ -0,0 +1,50 @@ +package com.nuvio.app.features.debrid + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TorboxDeviceAuthTest { + @Test + fun `maps unused device code response to pending`() { + val response = DebridApiResponse( + status = 400, + body = TorboxEnvelopeDto( + success = false, + detail = "This device code has not been used yet. Please wait for the user to scan the code.", + ), + rawBody = "", + ) + + assertEquals( + DebridDeviceAuthorizationTokenResult.Pending, + torboxDeviceAuthorizationTokenResult(response), + ) + } + + @Test + fun `maps authorized and expired Torbox device states`() { + assertTrue( + torboxDeviceAuthorizationTokenResult( + DebridApiResponse( + status = 200, + body = TorboxEnvelopeDto( + success = true, + data = TorboxDeviceTokenDto(accessToken = "tb-token", tokenType = "Bearer"), + ), + rawBody = "", + ), + ) is DebridDeviceAuthorizationTokenResult.Authorized, + ) + assertEquals( + DebridDeviceAuthorizationTokenResult.Expired, + torboxDeviceAuthorizationTokenResult( + DebridApiResponse( + status = 410, + body = TorboxEnvelopeDto(success = false, detail = "Device code expired."), + rawBody = "", + ), + ), + ) + } +}