diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.android.kt new file mode 100644 index 000000000..a2ccc03ca --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.android.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.simkl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import com.nuvio.app.R + +@Composable +actual fun simklBrandPainter(asset: SimklBrandAsset): Painter = + painterResource( + id = when (asset) { + SimklBrandAsset.Glyph -> R.drawable.simkl_logo_glyph + SimklBrandAsset.Wordmark -> R.drawable.simkl_logo_wordmark + }, + ) diff --git a/composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml b/composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml new file mode 100644 index 000000000..f53fead9b --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/simkl_logo_glyph.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/composeApp/src/androidMain/res/drawable/simkl_logo_wordmark.xml b/composeApp/src/androidMain/res/drawable/simkl_logo_wordmark.xml new file mode 100644 index 000000000..e1d934ccd --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/simkl_logo_wordmark.xml @@ -0,0 +1,13 @@ + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/simkl_logo_glyph.svg b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_glyph.svg new file mode 100644 index 000000000..e16507c24 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_glyph.svg @@ -0,0 +1 @@ + diff --git a/composeApp/src/commonMain/composeResources/drawable/simkl_logo_wordmark.svg b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_wordmark.svg new file mode 100644 index 000000000..3860c4563 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/simkl_logo_wordmark.svg @@ -0,0 +1 @@ + diff --git a/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg b/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg index 15d12e906..b7ecf23f1 100644 --- a/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg +++ b/composeApp/src/commonMain/composeResources/drawable/trakt_logo_wordmark.svg @@ -1,4 +1,4 @@ - + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 966f1b7c3..6a01bb0c6 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1121,10 +1121,24 @@ Open Trakt Login Your Save actions can now target Trakt watchlist and personal lists. Sign in with Trakt to enable list-based saving and Trakt library mode. - Connect one or more tracking services. Playback events are sent to every connected tracker, while you choose one source for library and watch progress views. + DATA SOURCES SOURCE PREFERENCES + VIEWING AND DISCOVERY TRACKING SERVICES After approval, you will be redirected back automatically. + Connect %1$s first + Disconnect %1$s? + This removes the connection from Nuvio. Your data on %1$s stays intact, and unavailable library or progress sources temporarily fall back to Nuvio. + %1$s is unavailable. Using %2$s until it reconnects. + The source changed, but the latest watch progress could not be refreshed. + Keep saved titles in your Nuvio library. + Use your Trakt watchlist and personal lists. + Use your Simkl plan-to-watch and status lists. + Resume with progress stored by Nuvio Sync. + Resume with playback progress from Trakt. + Resume with playback progress from Simkl. + Use TMDB recommendations on details pages. + Use personalized related titles from Trakt. Connect Simkl Connected as %1$s Simkl user diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt index f44bb8d31..ef2e17d6c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt @@ -230,11 +230,12 @@ internal fun SettingsSection( @Composable internal fun SettingsNavigationRow( title: String, - description: String, + description: String?, icon: ImageVector? = null, iconPainter: Painter? = null, enabled: Boolean = true, isTablet: Boolean, + trailingContent: (@Composable RowScope.() -> Unit)? = null, onClick: () -> Unit, ) { val tokens = MaterialTheme.nuvio @@ -294,15 +295,18 @@ internal fun SettingsNavigationRow( color = tokens.colors.textPrimary, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = tokens.colors.textMuted, - modifier = Modifier.alpha(0.92f), - ) + if (!description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textMuted, + modifier = Modifier.alpha(0.92f), + ) + } } } + trailingContent?.invoke(this) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingAdaptivePicker.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingAdaptivePicker.kt new file mode 100644 index 000000000..2d1c0b063 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingAdaptivePicker.kt @@ -0,0 +1,255 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.NuvioBottomSheetDivider +import com.nuvio.app.core.ui.NuvioModalBottomSheet +import com.nuvio.app.core.ui.dismissNuvioBottomSheet +import com.nuvio.app.core.ui.nuvio +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.cd_selected +import org.jetbrains.compose.resources.stringResource + +internal data class TrackingPickerOption( + val value: T, + val title: String, + val description: String? = null, + val enabled: Boolean = true, + val unavailableReason: String? = null, +) + +@Composable +internal fun TrackingAdaptivePicker( + isTablet: Boolean, + title: String, + subtitle: String, + selectedValue: T, + options: List>, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + if (isTablet) { + TrackingPickerDialog( + title = title, + subtitle = subtitle, + selectedValue = selectedValue, + options = options, + onSelected = onSelected, + onDismiss = onDismiss, + ) + } else { + TrackingPickerBottomSheet( + title = title, + subtitle = subtitle, + selectedValue = selectedValue, + options = options, + onSelected = onSelected, + onDismiss = onDismiss, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TrackingPickerBottomSheet( + title: String, + subtitle: String, + selectedValue: T, + options: List>, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + fun dismiss() { + scope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + } + + NuvioModalBottomSheet( + onDismissRequest = ::dismiss, + sheetState = sheetState, + ) { + TrackingPickerContent( + title = title, + subtitle = subtitle, + selectedValue = selectedValue, + options = options, + isTablet = false, + onSelected = { value -> + onSelected(value) + dismiss() + }, + onDismiss = ::dismiss, + modifier = Modifier.navigationBarsPadding(), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TrackingPickerDialog( + title: String, + subtitle: String, + selectedValue: T, + options: List>, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = tokens.components.dialogMaxWidth), + shape = tokens.shapes.dialog, + color = tokens.colors.surfaceDialog, + ) { + TrackingPickerContent( + title = title, + subtitle = subtitle, + selectedValue = selectedValue, + options = options, + isTablet = true, + onSelected = { value -> + onSelected(value) + onDismiss() + }, + onDismiss = onDismiss, + ) + } + } +} + +@Composable +private fun TrackingPickerContent( + title: String, + subtitle: String, + selectedValue: T, + options: List>, + isTablet: Boolean, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val tokens = MaterialTheme.nuvio + val horizontalPadding = if (isTablet) tokens.spacing.dialogPadding else tokens.spacing.screenHorizontal + Column( + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = horizontalPadding, + end = horizontalPadding, + top = if (isTablet) tokens.spacing.dialogPadding else 12.dp, + bottom = 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = tokens.colors.textPrimary, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textMuted, + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 520.dp) + .verticalScroll(rememberScrollState()), + ) { + options.forEachIndexed { index, option -> + if (index > 0) { + NuvioBottomSheetDivider() + } + TrackingPickerOptionRow( + option = option, + selected = option.value == selectedValue, + isTablet = isTablet, + onClick = { onSelected(option.value) }, + ) + } + } + + if (isTablet) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding, vertical = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_close)) + } + } + } + } +} + +@Composable +private fun TrackingPickerOptionRow( + option: TrackingPickerOption, + selected: Boolean, + isTablet: Boolean, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val description = listOfNotNull( + option.description?.takeIf(String::isNotBlank), + option.unavailableReason?.takeIf(String::isNotBlank), + ).joinToString("\n").takeIf(String::isNotBlank) + SettingsNavigationRow( + title = option.title, + description = description, + enabled = option.enabled, + isTablet = isTablet, + trailingContent = { + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = tokens.colors.accent, + modifier = Modifier.size(24.dp), + ) + } + }, + onClick = onClick, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt new file mode 100644 index 000000000..c552b8ba2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingProviderCards.kt @@ -0,0 +1,765 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Sync +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +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.ui.NuvioLoadingIndicator +import com.nuvio.app.core.ui.NuvioTokens +import com.nuvio.app.core.ui.nuvio +import com.nuvio.app.features.simkl.SimklAuthError +import com.nuvio.app.features.simkl.SimklAuthRepository +import com.nuvio.app.features.simkl.SimklAuthUiState +import com.nuvio.app.features.simkl.SimklBrandAsset +import com.nuvio.app.features.simkl.SimklConnectionMode +import com.nuvio.app.features.simkl.SimklSyncRepository +import com.nuvio.app.features.simkl.simklBrandPainter +import com.nuvio.app.features.tracking.TrackingRefreshIntent +import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktAuthUiState +import com.nuvio.app.features.trakt.TraktBrandAsset +import com.nuvio.app.features.trakt.TraktConnectionMode +import com.nuvio.app.features.trakt.traktBrandPainter +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.settings_simkl_authorization_expired +import nuvio.composeapp.generated.resources.settings_simkl_authorization_revoked +import nuvio.composeapp.generated.resources.settings_simkl_connect +import nuvio.composeapp.generated.resources.settings_simkl_connected_as +import nuvio.composeapp.generated.resources.settings_simkl_connected_description +import nuvio.composeapp.generated.resources.settings_simkl_default_user +import nuvio.composeapp.generated.resources.settings_simkl_disconnect +import nuvio.composeapp.generated.resources.settings_simkl_finish_sign_in +import nuvio.composeapp.generated.resources.settings_simkl_invalid_callback +import nuvio.composeapp.generated.resources.settings_simkl_missing_credentials +import nuvio.composeapp.generated.resources.settings_simkl_open_login +import nuvio.composeapp.generated.resources.settings_simkl_sign_in_description +import nuvio.composeapp.generated.resources.settings_simkl_sign_in_failed +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_action +import nuvio.composeapp.generated.resources.settings_simkl_sync_now +import nuvio.composeapp.generated.resources.settings_simkl_visit +import nuvio.composeapp.generated.resources.settings_tracking_approval_redirect +import nuvio.composeapp.generated.resources.settings_tracking_disconnect_description +import nuvio.composeapp.generated.resources.settings_tracking_disconnect_title +import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect +import nuvio.composeapp.generated.resources.settings_trakt_connect +import nuvio.composeapp.generated.resources.settings_trakt_connected_as +import nuvio.composeapp.generated.resources.settings_trakt_default_user +import nuvio.composeapp.generated.resources.settings_trakt_disconnect +import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser +import nuvio.composeapp.generated.resources.settings_trakt_finish_sign_in +import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials +import nuvio.composeapp.generated.resources.settings_trakt_open_login +import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description +import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description +import org.jetbrains.compose.resources.stringResource + +internal enum class TrackingBrand(val displayName: String) { + NUVIO("Nuvio"), + TRAKT("Trakt"), + SIMKL("Simkl"), + TMDB("TMDB"), +} + +internal enum class TrackingConnectionCardMode { + DISCONNECTED, + AWAITING_APPROVAL, + CONNECTED, +} + +internal fun isTrackingBrandAvailable( + brand: TrackingBrand, + traktConnected: Boolean, + simklConnected: Boolean, +): Boolean = when (brand) { + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> true + TrackingBrand.TRAKT -> traktConnected + TrackingBrand.SIMKL -> simklConnected +} + +internal fun TraktConnectionMode.toTrackingConnectionCardMode(): TrackingConnectionCardMode = when (this) { + TraktConnectionMode.DISCONNECTED -> TrackingConnectionCardMode.DISCONNECTED + TraktConnectionMode.AWAITING_APPROVAL -> TrackingConnectionCardMode.AWAITING_APPROVAL + TraktConnectionMode.CONNECTED -> TrackingConnectionCardMode.CONNECTED +} + +internal fun SimklConnectionMode.toTrackingConnectionCardMode(): TrackingConnectionCardMode = when (this) { + SimklConnectionMode.DISCONNECTED -> TrackingConnectionCardMode.DISCONNECTED + SimklConnectionMode.AWAITING_APPROVAL -> TrackingConnectionCardMode.AWAITING_APPROVAL + SimklConnectionMode.CONNECTED -> TrackingConnectionCardMode.CONNECTED +} + +@Composable +internal fun TrackingProviderCards( + isTablet: Boolean, + traktUiState: TraktAuthUiState, + simklUiState: SimklAuthUiState, +) { + val syncState by remember { + SimklSyncRepository.ensureLoaded() + SimklSyncRepository.state + }.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + var showSyncInfo by rememberSaveable { mutableStateOf(false) } + + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val useTwoColumns = maxWidth >= 600.dp + if (useTwoColumns) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + TraktProviderCard( + uiState = traktUiState, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + SimklProviderCard( + uiState = simklUiState, + isSyncing = syncState.isLoading, + syncErrorMessage = syncState.errorMessage, + onSyncRequested = { + scope.launch { + SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) + } + }, + onInfoRequested = { showSyncInfo = true }, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + } + } else { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp), + ) { + TraktProviderCard( + uiState = traktUiState, + modifier = Modifier.fillMaxWidth(), + ) + SimklProviderCard( + uiState = simklUiState, + isSyncing = syncState.isLoading, + syncErrorMessage = syncState.errorMessage, + onSyncRequested = { + scope.launch { + SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) + } + }, + onInfoRequested = { showSyncInfo = true }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + if (showSyncInfo) { + SimklSyncInfoDialog(onDismiss = { showSyncInfo = false }) + } +} + +@Composable +private fun TraktProviderCard( + uiState: TraktAuthUiState, + modifier: Modifier, +) { + TrackingProviderCard( + brand = TrackingBrand.TRAKT, + mode = uiState.mode.toTrackingConnectionCardMode(), + credentialsConfigured = uiState.credentialsConfigured, + isLoading = uiState.isLoading, + connectedLabel = stringResource( + Res.string.settings_trakt_connected_as, + uiState.username ?: stringResource(Res.string.settings_trakt_default_user), + ), + connectedDescription = stringResource(Res.string.settings_trakt_save_actions_description), + signInDescription = stringResource(Res.string.settings_trakt_sign_in_description), + finishSignInLabel = stringResource(Res.string.settings_trakt_finish_sign_in), + approvalDescription = stringResource(Res.string.settings_trakt_approval_redirect), + connectLabel = stringResource(Res.string.settings_trakt_connect), + openLoginLabel = stringResource(Res.string.settings_trakt_open_login), + disconnectLabel = stringResource(Res.string.settings_trakt_disconnect), + missingCredentialsMessage = stringResource(Res.string.settings_trakt_missing_credentials), + statusMessage = uiState.statusMessage.takeUnless { + uiState.mode == TraktConnectionMode.CONNECTED + }, + errorMessage = uiState.errorMessage, + onConnectRequested = TraktAuthRepository::onConnectRequested, + onResumeAuthorization = { + TraktAuthRepository.pendingAuthorizationUrl() + ?: TraktAuthRepository.onConnectRequested() + }, + onCancelAuthorization = TraktAuthRepository::onCancelAuthorization, + onDisconnect = TraktAuthRepository::onDisconnectRequested, + modifier = modifier, + ) +} + +@Composable +private fun SimklProviderCard( + uiState: SimklAuthUiState, + isSyncing: Boolean, + syncErrorMessage: String?, + onSyncRequested: () -> Unit, + onInfoRequested: () -> Unit, + modifier: Modifier, +) { + TrackingProviderCard( + brand = TrackingBrand.SIMKL, + mode = uiState.mode.toTrackingConnectionCardMode(), + credentialsConfigured = uiState.credentialsConfigured, + isLoading = uiState.isLoading, + connectedLabel = stringResource( + Res.string.settings_simkl_connected_as, + uiState.username ?: stringResource(Res.string.settings_simkl_default_user), + ), + connectedDescription = stringResource(Res.string.settings_simkl_connected_description), + signInDescription = stringResource(Res.string.settings_simkl_sign_in_description), + finishSignInLabel = stringResource(Res.string.settings_simkl_finish_sign_in), + approvalDescription = stringResource(Res.string.settings_tracking_approval_redirect), + connectLabel = stringResource(Res.string.settings_simkl_connect), + openLoginLabel = stringResource(Res.string.settings_simkl_open_login), + disconnectLabel = stringResource(Res.string.settings_simkl_disconnect), + syncLabel = stringResource(Res.string.settings_simkl_sync_now), + infoLabel = stringResource(Res.string.settings_simkl_sync_info_action), + isSyncing = isSyncing, + missingCredentialsMessage = stringResource(Res.string.settings_simkl_missing_credentials), + errorMessage = simklErrorMessage(uiState.error) ?: syncErrorMessage, + websiteLabel = stringResource(Res.string.settings_simkl_visit), + websiteUrl = SIMKL_WEBSITE_URL, + onConnectRequested = SimklAuthRepository::onConnectRequested, + onResumeAuthorization = { + SimklAuthRepository.pendingAuthorizationUrl() + ?: SimklAuthRepository.onConnectRequested() + }, + onCancelAuthorization = SimklAuthRepository::onCancelAuthorization, + onSyncRequested = onSyncRequested, + onInfoRequested = onInfoRequested, + onDisconnect = SimklAuthRepository::onDisconnectRequested, + modifier = modifier, + ) +} + +@Composable +private fun TrackingProviderCard( + brand: TrackingBrand, + mode: TrackingConnectionCardMode, + credentialsConfigured: Boolean, + isLoading: Boolean, + connectedLabel: String, + connectedDescription: String, + signInDescription: String, + finishSignInLabel: String, + approvalDescription: String, + connectLabel: String, + openLoginLabel: String, + disconnectLabel: String, + missingCredentialsMessage: String, + modifier: Modifier = Modifier, + syncLabel: String? = null, + infoLabel: String? = null, + isSyncing: Boolean = false, + statusMessage: String? = null, + errorMessage: String? = null, + websiteLabel: String? = null, + websiteUrl: String? = null, + onConnectRequested: () -> String?, + onResumeAuthorization: () -> String?, + onCancelAuthorization: () -> Unit, + onSyncRequested: (() -> Unit)? = null, + onInfoRequested: (() -> Unit)? = null, + onDisconnect: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val uriHandler = LocalUriHandler.current + val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser) + var browserError by rememberSaveable { mutableStateOf(false) } + var showDisconnectDialog by rememberSaveable { mutableStateOf(false) } + + fun openUrl(url: String?) { + if (url.isNullOrBlank()) return + browserError = false + runCatching { uriHandler.openUri(url) } + .onFailure { browserError = true } + } + + Box( + modifier = modifier + .clip(tokens.shapes.card) + .background(brand.cardBrush(), tokens.shapes.card) + .border( + width = tokens.borders.hairline, + color = Color.White.copy(alpha = 0.2f), + shape = tokens.shapes.card, + ), + ) { + TrackingBrandGlyph( + brand = brand, + contentDescription = null, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(12.dp) + .size(150.dp) + .alpha(0.08f), + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(if (mode == TrackingConnectionCardMode.CONNECTED) 20.dp else 18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + TrackingBrandWordmark( + brand = brand, + contentDescription = brand.displayName, + ) + + when (mode) { + TrackingConnectionCardMode.CONNECTED -> { + TrackingConnectedIdentity( + label = connectedLabel, + description = connectedDescription, + ) + if (syncLabel != null && onSyncRequested != null) { + TrackingBrandPrimaryButton( + label = syncLabel, + loading = isSyncing, + enabled = !isLoading && !isSyncing, + onClick = onSyncRequested, + showSyncIcon = true, + ) + } + } + + TrackingConnectionCardMode.AWAITING_APPROVAL -> { + Text( + text = finishSignInLabel, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = approvalDescription, + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.78f), + ) + TrackingBrandPrimaryButton( + label = openLoginLabel, + loading = isLoading, + enabled = !isLoading, + onClick = { openUrl(onResumeAuthorization()) }, + ) + OutlinedButton( + onClick = onCancelAuthorization, + enabled = !isLoading, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.44f)), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White, + disabledContentColor = Color.White.copy(alpha = 0.45f), + ), + ) { + Text(stringResource(Res.string.action_cancel)) + } + } + + TrackingConnectionCardMode.DISCONNECTED -> { + Text( + text = signInDescription, + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.82f), + ) + TrackingBrandPrimaryButton( + label = connectLabel, + loading = isLoading, + enabled = credentialsConfigured && !isLoading, + onClick = { openUrl(onConnectRequested()) }, + ) + if (!credentialsConfigured) { + TrackingBrandMessage( + text = missingCredentialsMessage, + isError = true, + ) + } + } + } + + statusMessage?.takeIf(String::isNotBlank)?.let { message -> + TrackingBrandMessage(text = message, isError = false) + } + errorMessage?.takeIf(String::isNotBlank)?.let { message -> + TrackingBrandMessage(text = message, isError = true) + } + if (browserError) { + TrackingBrandMessage(text = failedOpenBrowserMessage, isError = true) + } + + val hasWebsiteAction = !websiteLabel.isNullOrBlank() && !websiteUrl.isNullOrBlank() + val hasDisconnectAction = mode == TrackingConnectionCardMode.CONNECTED + val hasInfoAction = mode == TrackingConnectionCardMode.CONNECTED && + infoLabel != null && onInfoRequested != null + val footerActionCount = listOf( + hasWebsiteAction, + hasDisconnectAction, + hasInfoAction, + ).count { it } + if (footerActionCount > 0) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasWebsiteAction) { + TextButton( + onClick = { openUrl(websiteUrl) }, + modifier = if (footerActionCount > 1) Modifier.weight(0.95f) else Modifier, + contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp), + colors = ButtonDefaults.textButtonColors(contentColor = Color.White), + ) { + Text( + text = websiteLabel.orEmpty(), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (hasDisconnectAction) { + TextButton( + onClick = { showDisconnectDialog = true }, + modifier = if (footerActionCount > 1) Modifier.weight(1.05f) else Modifier, + enabled = !isLoading && !isSyncing, + contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp), + colors = ButtonDefaults.textButtonColors( + contentColor = Color.White.copy(alpha = 0.84f), + disabledContentColor = Color.White.copy(alpha = 0.38f), + ), + ) { + Text( + text = disconnectLabel, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (hasInfoAction) { + TextButton( + onClick = { onInfoRequested?.invoke() }, + modifier = if (footerActionCount > 1) Modifier.weight(1.55f) else Modifier, + contentPadding = PaddingValues(horizontal = 2.dp, vertical = 0.dp), + colors = ButtonDefaults.textButtonColors(contentColor = Color.White), + ) { + Text( + text = infoLabel.orEmpty(), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + } + + if (showDisconnectDialog) { + TrackingDisconnectDialog( + brand = brand, + onConfirm = { + showDisconnectDialog = false + onDisconnect() + }, + onDismiss = { showDisconnectDialog = false }, + ) + } +} + +@Composable +private fun TrackingConnectedIdentity( + label: String, + description: String, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = Color.White, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.76f), + ) + } +} + +@Composable +private fun TrackingBrandPrimaryButton( + label: String, + loading: Boolean, + enabled: Boolean, + onClick: () -> Unit, + showSyncIcon: Boolean = false, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color(0xFF171717), + disabledContainerColor = Color.White.copy(alpha = 0.34f), + disabledContentColor = Color.White.copy(alpha = 0.7f), + ), + ) { + when { + loading -> NuvioLoadingIndicator( + color = Color(0xFF171717), + modifier = Modifier.size(18.dp), + ) + showSyncIcon -> Icon( + imageVector = Icons.Rounded.Sync, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + if (loading || showSyncIcon) { + Spacer(modifier = Modifier.width(8.dp)) + } + Text(label) + } +} + +@Composable +private fun TrackingBrandMessage( + text: String, + isError: Boolean, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = if (isError) TrackingErrorColor.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.1f), + shape = RoundedCornerShape(NuvioTokens.Radius.md), + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = MaterialTheme.typography.bodySmall, + color = if (isError) TrackingErrorColor else Color.White.copy(alpha = 0.82f), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TrackingDisconnectDialog( + brand: TrackingBrand, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = tokens.components.dialogMaxWidth), + shape = tokens.shapes.dialog, + color = tokens.colors.surfaceDialog, + ) { + Column( + modifier = Modifier.padding(tokens.spacing.dialogPadding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(Res.string.settings_tracking_disconnect_title, brand.displayName), + style = MaterialTheme.typography.titleLarge, + color = tokens.colors.textPrimary, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.settings_tracking_disconnect_description, brand.displayName), + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textMuted, + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_cancel)) + } + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(Res.string.settings_trakt_disconnect)) + } + } + } + } + } +} + +@Composable +internal fun TrackingBrandGlyph( + brand: TrackingBrand, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + when (brand) { + TrackingBrand.TRAKT -> Image( + painter = traktBrandPainter(TraktBrandAsset.Glyph), + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Fit, + ) + TrackingBrand.SIMKL -> Image( + painter = simklBrandPainter(SimklBrandAsset.Glyph), + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Fit, + ) + TrackingBrand.TMDB -> Image( + painter = integrationLogoPainter(IntegrationLogo.Tmdb), + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Fit, + ) + TrackingBrand.NUVIO -> Icon( + imageVector = Icons.Rounded.Sync, + contentDescription = contentDescription, + modifier = modifier, + tint = MaterialTheme.nuvio.colors.accent, + ) + } +} + +@Composable +private fun TrackingBrandWordmark( + brand: TrackingBrand, + contentDescription: String, +) { + val painter: Painter = when (brand) { + TrackingBrand.TRAKT -> traktBrandPainter(TraktBrandAsset.Wordmark) + TrackingBrand.SIMKL -> simklBrandPainter(SimklBrandAsset.Wordmark) + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> return + } + Image( + painter = painter, + contentDescription = contentDescription, + modifier = when (brand) { + TrackingBrand.TRAKT -> Modifier + .width(86.dp) + .height(38.dp) + TrackingBrand.SIMKL -> Modifier + .width(124.dp) + .height(30.dp) + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> Modifier + }, + contentScale = ContentScale.Fit, + alignment = Alignment.CenterStart, + ) +} + +private fun TrackingBrand.cardBrush(): Brush = when (this) { + TrackingBrand.TRAKT -> Brush.linearGradient( + colors = listOf(Color(0xFF7D279B), Color(0xFFD61F56), Color(0xFFF22125)), + ) + TrackingBrand.SIMKL -> Brush.linearGradient( + colors = listOf(Color(0xFF050505), Color(0xFF292929), Color(0xFF111111)), + ) + TrackingBrand.NUVIO, + TrackingBrand.TMDB, + -> Brush.linearGradient(colors = listOf(Color(0xFF242424), Color(0xFF111111))) +} + +@Composable +private fun simklErrorMessage(error: SimklAuthError?): String? = when (error) { + null, SimklAuthError.MISSING_CLIENT_ID -> null + SimklAuthError.INVALID_CALLBACK, + SimklAuthError.INVALID_CALLBACK_STATE, + -> stringResource(Res.string.settings_simkl_invalid_callback) + SimklAuthError.AUTHORIZATION_EXPIRED -> + stringResource(Res.string.settings_simkl_authorization_expired) + SimklAuthError.TOKEN_EXCHANGE_FAILED, + SimklAuthError.INVALID_TOKEN_RESPONSE, + -> stringResource(Res.string.settings_simkl_sign_in_failed) + SimklAuthError.AUTHORIZATION_REVOKED -> + stringResource(Res.string.settings_simkl_authorization_revoked) +} + +private val TrackingErrorColor = Color(0xFFFFDAD6) +private const val SIMKL_WEBSITE_URL = "https://simkl.com" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt index f7e39baef..3d702ce9f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt @@ -1,29 +1,12 @@ package com.nuvio.app.features.settings -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import com.nuvio.app.core.ui.NuvioLoadingIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon 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 @@ -33,79 +16,52 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioLoadingIndicator +import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.profiles.ProfileRepository -import com.nuvio.app.features.simkl.SimklAuthError -import com.nuvio.app.features.simkl.SimklAuthRepository import com.nuvio.app.features.simkl.SimklAuthUiState import com.nuvio.app.features.simkl.SimklConnectionMode -import com.nuvio.app.features.simkl.SimklSyncRepository -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktAuthUiState -import com.nuvio.app.features.trakt.TraktConnectionMode -import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions -import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference import com.nuvio.app.features.tracking.TrackingProviderId -import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.TrackingSettingsUiState import com.nuvio.app.features.tracking.WatchProgressSource import com.nuvio.app.features.tracking.effectiveLibrarySourceMode import com.nuvio.app.features.tracking.effectiveWatchProgressSource +import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL +import com.nuvio.app.features.trakt.TraktAuthUiState +import com.nuvio.app.features.trakt.TraktConnectionMode +import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.action_cancel -import nuvio.composeapp.generated.resources.settings_playback_dialog_close -import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect -import nuvio.composeapp.generated.resources.settings_trakt_authentication +import nuvio.composeapp.generated.resources.action_retry +import nuvio.composeapp.generated.resources.settings_tracking_connect_first +import nuvio.composeapp.generated.resources.settings_tracking_data_sources +import nuvio.composeapp.generated.resources.settings_tracking_nuvio_library_description +import nuvio.composeapp.generated.resources.settings_tracking_nuvio_progress_description +import nuvio.composeapp.generated.resources.settings_tracking_progress_refresh_failed +import nuvio.composeapp.generated.resources.settings_tracking_services +import nuvio.composeapp.generated.resources.settings_tracking_simkl_library_description +import nuvio.composeapp.generated.resources.settings_tracking_simkl_progress_description +import nuvio.composeapp.generated.resources.settings_tracking_source_fallback +import nuvio.composeapp.generated.resources.settings_tracking_tmdb_recommendations_description +import nuvio.composeapp.generated.resources.settings_tracking_trakt_library_description +import nuvio.composeapp.generated.resources.settings_tracking_trakt_progress_description +import nuvio.composeapp.generated.resources.settings_tracking_trakt_recommendations_description +import nuvio.composeapp.generated.resources.settings_tracking_viewing_discovery import nuvio.composeapp.generated.resources.settings_trakt_comments import nuvio.composeapp.generated.resources.settings_trakt_comments_description -import nuvio.composeapp.generated.resources.settings_trakt_connect -import nuvio.composeapp.generated.resources.settings_trakt_connected_as -import nuvio.composeapp.generated.resources.settings_trakt_default_user -import nuvio.composeapp.generated.resources.settings_trakt_disconnect -import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser -import nuvio.composeapp.generated.resources.settings_trakt_features -import nuvio.composeapp.generated.resources.settings_trakt_finish_sign_in -import nuvio.composeapp.generated.resources.settings_trakt_intro_description -import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials -import nuvio.composeapp.generated.resources.settings_trakt_open_login -import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description -import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description -import nuvio.composeapp.generated.resources.settings_tracking_approval_redirect -import nuvio.composeapp.generated.resources.settings_tracking_features -import nuvio.composeapp.generated.resources.settings_tracking_intro_description -import nuvio.composeapp.generated.resources.settings_simkl_authorization_expired -import nuvio.composeapp.generated.resources.settings_simkl_authorization_revoked -import nuvio.composeapp.generated.resources.settings_simkl_connect -import nuvio.composeapp.generated.resources.settings_simkl_connected_as -import nuvio.composeapp.generated.resources.settings_simkl_connected_description -import nuvio.composeapp.generated.resources.settings_simkl_default_user -import nuvio.composeapp.generated.resources.settings_simkl_disconnect -import nuvio.composeapp.generated.resources.settings_simkl_finish_sign_in -import nuvio.composeapp.generated.resources.settings_simkl_invalid_callback -import nuvio.composeapp.generated.resources.settings_simkl_missing_credentials -import nuvio.composeapp.generated.resources.settings_simkl_open_login -import nuvio.composeapp.generated.resources.settings_simkl_sign_in_description -import nuvio.composeapp.generated.resources.settings_simkl_sign_in_failed -import nuvio.composeapp.generated.resources.settings_simkl_sync_now -import nuvio.composeapp.generated.resources.settings_simkl_sync_info_action -import nuvio.composeapp.generated.resources.settings_simkl_visit -import nuvio.composeapp.generated.resources.tracking_library_source_simkl_selected import nuvio.composeapp.generated.resources.tracking_source_simkl import nuvio.composeapp.generated.resources.tracking_watch_progress_dialog_subtitle -import nuvio.composeapp.generated.resources.tracking_watch_progress_simkl_selected import nuvio.composeapp.generated.resources.trakt_all_history import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle import nuvio.composeapp.generated.resources.trakt_continue_watching_window @@ -115,25 +71,20 @@ import nuvio.composeapp.generated.resources.trakt_days_format import nuvio.composeapp.generated.resources.trakt_library_source_dialog_subtitle import nuvio.composeapp.generated.resources.trakt_library_source_dialog_title import nuvio.composeapp.generated.resources.trakt_library_source_nuvio -import nuvio.composeapp.generated.resources.trakt_library_source_nuvio_selected import nuvio.composeapp.generated.resources.trakt_library_source_subtitle import nuvio.composeapp.generated.resources.trakt_library_source_title import nuvio.composeapp.generated.resources.trakt_library_source_trakt -import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_subtitle import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_title import nuvio.composeapp.generated.resources.trakt_more_like_this_source_subtitle import nuvio.composeapp.generated.resources.trakt_more_like_this_source_title import nuvio.composeapp.generated.resources.trakt_more_like_this_source_tmdb import nuvio.composeapp.generated.resources.trakt_more_like_this_source_trakt -import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title -import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected import nuvio.composeapp.generated.resources.trakt_watch_progress_source_nuvio import nuvio.composeapp.generated.resources.trakt_watch_progress_source_trakt import nuvio.composeapp.generated.resources.trakt_watch_progress_subtitle import nuvio.composeapp.generated.resources.trakt_watch_progress_title -import nuvio.composeapp.generated.resources.trakt_watch_progress_trakt_selected import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.trackingSettingsContent( @@ -145,310 +96,438 @@ internal fun LazyListScope.trackingSettingsContent( onCommentsEnabledChange: (Boolean) -> Unit, ) { item { - SettingsGroup(isTablet = isTablet) { - TrackingIntro(isTablet = isTablet) + SettingsSection( + title = stringResource(Res.string.settings_tracking_services), + isTablet = isTablet, + ) { + TrackingProviderCards( + isTablet = isTablet, + traktUiState = traktUiState, + simklUiState = simklUiState, + ) } } item { SettingsSection( - title = "TRAKT", + title = stringResource(Res.string.settings_tracking_data_sources), isTablet = isTablet, ) { - SettingsGroup(isTablet = isTablet) { - TraktConnectionCard( - isTablet = isTablet, - uiState = traktUiState, - ) - } + TrackingDataSources( + isTablet = isTablet, + settingsUiState = settingsUiState, + traktConnected = traktUiState.mode == TraktConnectionMode.CONNECTED, + simklConnected = simklUiState.mode == SimklConnectionMode.CONNECTED, + ) } } item { SettingsSection( - title = "SIMKL", + title = stringResource(Res.string.settings_tracking_viewing_discovery), isTablet = isTablet, ) { - SettingsGroup(isTablet = isTablet) { - SimklConnectionCard( - isTablet = isTablet, - uiState = simklUiState, - ) - } - } - } - - item { - SettingsSection( - title = stringResource(Res.string.settings_tracking_features), - isTablet = isTablet, - ) { - SettingsGroup(isTablet = isTablet) { - TrackingFeatureRows( - isTablet = isTablet, - settingsUiState = settingsUiState, - traktConnected = traktUiState.mode == TraktConnectionMode.CONNECTED, - simklConnected = simklUiState.mode == SimklConnectionMode.CONNECTED, - commentsEnabled = commentsEnabled, - onCommentsEnabledChange = onCommentsEnabledChange, - ) - } + TrackingViewingAndDiscovery( + isTablet = isTablet, + settingsUiState = settingsUiState, + traktConnected = traktUiState.mode == TraktConnectionMode.CONNECTED, + commentsEnabled = commentsEnabled, + onCommentsEnabledChange = onCommentsEnabledChange, + ) } } } +private enum class TrackingDataPicker { + LIBRARY, + WATCH_PROGRESS, +} + @Composable -private fun TrackingFeatureRows( +private fun TrackingDataSources( isTablet: Boolean, settingsUiState: TrackingSettingsUiState, traktConnected: Boolean, simklConnected: Boolean, - commentsEnabled: Boolean, - onCommentsEnabledChange: (Boolean) -> Unit, ) { - var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) } - var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) } - var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) } - var showMoreLikeThisSourceDialog by rememberSaveable { mutableStateOf(false) } - var statusMessage by rememberSaveable { mutableStateOf(null) } + var activePickerName by rememberSaveable { mutableStateOf(null) } + val activePicker = activePickerName?.let(TrackingDataPicker::valueOf) val scope = rememberCoroutineScope() - + val transitionState by remember { + WatchProgressSourceCoordinator.ensureStarted() + WatchProgressSourceCoordinator.uiState + }.collectAsStateWithLifecycle() val connectedProviders = buildSet { if (traktConnected) add(TrackingProviderId.TRAKT) if (simklConnected) add(TrackingProviderId.SIMKL) } - val selectedLibrarySource = effectiveLibrarySourceMode(settingsUiState.librarySourceMode) { - providerId -> providerId in connectedProviders + val effectiveLibrarySource = effectiveLibrarySourceMode(settingsUiState.librarySourceMode) { provider -> + provider in connectedProviders } - val selectedWatchProgressSource = effectiveWatchProgressSource(settingsUiState.watchProgressSource) { - providerId -> providerId in connectedProviders + val effectiveProgressSource = effectiveWatchProgressSource(settingsUiState.watchProgressSource) { provider -> + provider in connectedProviders } - val availableLibrarySources = buildList { - add(LibrarySourceMode.LOCAL) - if (traktConnected) add(LibrarySourceMode.TRAKT) - if (simklConnected) add(LibrarySourceMode.SIMKL) + val libraryFallback = if (effectiveLibrarySource != settingsUiState.librarySourceMode) { + stringResource( + Res.string.settings_tracking_source_fallback, + librarySourceModeLabel(settingsUiState.librarySourceMode), + librarySourceModeLabel(effectiveLibrarySource), + ) + } else { + null } - val availableWatchProgressSources = buildList { - add(WatchProgressSource.NUVIO_SYNC) - if (traktConnected) add(WatchProgressSource.TRAKT) - if (simklConnected) add(WatchProgressSource.SIMKL) + val progressFallback = if (effectiveProgressSource != settingsUiState.watchProgressSource) { + stringResource( + Res.string.settings_tracking_source_fallback, + watchProgressSourceLabel(settingsUiState.watchProgressSource), + watchProgressSourceLabel(effectiveProgressSource), + ) + } else { + null } - val librarySourceValue = librarySourceModeLabel(selectedLibrarySource) - val watchProgressValue = watchProgressSourceLabel(selectedWatchProgressSource) - val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap) - val moreLikeThisSourceValue = moreLikeThisSourceLabel(settingsUiState.moreLikeThisSource) - val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected) - val simklProgressSelectedMessage = stringResource(Res.string.tracking_watch_progress_simkl_selected) - val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected) - val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected) - val simklLibrarySelectedMessage = stringResource(Res.string.tracking_library_source_simkl_selected) - val nuvioLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_nuvio_selected) + SettingsGroup(isTablet = isTablet) { + TrackingPreferenceActionRow( + title = stringResource(Res.string.trakt_library_source_title), + description = stringResource(Res.string.trakt_library_source_subtitle), + value = librarySourceModeLabel(effectiveLibrarySource), + supportingMessage = libraryFallback, + isTablet = isTablet, + onClick = { activePickerName = TrackingDataPicker.LIBRARY.name }, + ) + SettingsGroupDivider(isTablet = isTablet) + TrackingPreferenceActionRow( + title = stringResource(Res.string.trakt_watch_progress_title), + description = stringResource(Res.string.trakt_watch_progress_subtitle), + value = watchProgressSourceLabel(effectiveProgressSource), + supportingMessage = progressFallback, + isLoading = transitionState.isRefreshing, + isTablet = isTablet, + onClick = { activePickerName = TrackingDataPicker.WATCH_PROGRESS.name }, + ) + if (transitionState.lastRefreshSucceeded == false && !transitionState.isRefreshing) { + SettingsGroupDivider(isTablet = isTablet) + TrackingInlineErrorRow( + isTablet = isTablet, + message = stringResource(Res.string.settings_tracking_progress_refresh_failed), + onRetry = { + scope.launch { + WatchProgressSourceCoordinator.refreshActiveSource(ProfileRepository.activeProfileId) + } + }, + ) + } + } - TrackingSettingsActionRow( - title = stringResource(Res.string.trakt_library_source_title), - description = stringResource(Res.string.trakt_library_source_subtitle), - value = librarySourceValue, - isTablet = isTablet, - onClick = { showLibrarySourceDialog = true }, + when (activePicker) { + TrackingDataPicker.LIBRARY -> TrackingAdaptivePicker( + isTablet = isTablet, + title = stringResource(Res.string.trakt_library_source_dialog_title), + subtitle = stringResource(Res.string.trakt_library_source_dialog_subtitle), + selectedValue = effectiveLibrarySource, + options = librarySourceOptions(traktConnected, simklConnected), + onSelected = TrackingSettingsRepository::setLibrarySourceMode, + onDismiss = { activePickerName = null }, + ) + TrackingDataPicker.WATCH_PROGRESS -> TrackingAdaptivePicker( + isTablet = isTablet, + title = stringResource(Res.string.trakt_watch_progress_dialog_title), + subtitle = stringResource(Res.string.tracking_watch_progress_dialog_subtitle), + selectedValue = effectiveProgressSource, + options = watchProgressSourceOptions(traktConnected, simklConnected), + onSelected = { source -> + scope.launch { + WatchProgressSourceCoordinator.selectSource( + profileId = ProfileRepository.activeProfileId, + source = source, + ) + } + }, + onDismiss = { activePickerName = null }, + ) + null -> Unit + } +} + +private enum class TrackingViewingPicker { + CONTINUE_WATCHING, + MORE_LIKE_THIS, +} + +@Composable +private fun TrackingViewingAndDiscovery( + isTablet: Boolean, + settingsUiState: TrackingSettingsUiState, + traktConnected: Boolean, + commentsEnabled: Boolean, + onCommentsEnabledChange: (Boolean) -> Unit, +) { + var activePickerName by rememberSaveable { mutableStateOf(null) } + val activePicker = activePickerName?.let(TrackingViewingPicker::valueOf) + val effectiveRecommendationsSource = effectiveTrackingRecommendationsSource( + source = settingsUiState.moreLikeThisSource, + traktConnected = traktConnected, ) - SettingsGroupDivider(isTablet = isTablet) - TrackingSettingsActionRow( - title = stringResource(Res.string.trakt_watch_progress_title), - description = stringResource(Res.string.trakt_watch_progress_subtitle), - value = watchProgressValue, - isTablet = isTablet, - onClick = { showWatchProgressDialog = true }, + val recommendationsFallback = if (effectiveRecommendationsSource != settingsUiState.moreLikeThisSource) { + stringResource( + Res.string.settings_tracking_source_fallback, + moreLikeThisSourceLabel(settingsUiState.moreLikeThisSource), + moreLikeThisSourceLabel(effectiveRecommendationsSource), + ) + } else { + null + } + val connectTraktFirst = stringResource( + Res.string.settings_tracking_connect_first, + TrackingBrand.TRAKT.displayName, ) - SettingsGroupDivider(isTablet = isTablet) - TrackingSettingsActionRow( - title = stringResource(Res.string.trakt_continue_watching_window), - description = stringResource(Res.string.trakt_continue_watching_subtitle), - value = continueWatchingWindowValue, - isTablet = isTablet, - onClick = { showContinueWatchingWindowDialog = true }, - ) - if (traktConnected) { + + SettingsGroup(isTablet = isTablet) { + TrackingPreferenceActionRow( + title = stringResource(Res.string.trakt_continue_watching_window), + description = stringResource(Res.string.trakt_continue_watching_subtitle), + value = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap), + isTablet = isTablet, + onClick = { activePickerName = TrackingViewingPicker.CONTINUE_WATCHING.name }, + ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( title = stringResource(Res.string.settings_trakt_comments), - description = stringResource(Res.string.settings_trakt_comments_description), + description = listOfNotNull( + stringResource(Res.string.settings_trakt_comments_description), + connectTraktFirst.takeUnless { traktConnected }, + ).joinToString("\n"), checked = commentsEnabled, + enabled = traktConnected, isTablet = isTablet, onCheckedChange = onCommentsEnabledChange, ) SettingsGroupDivider(isTablet = isTablet) - TrackingSettingsActionRow( + TrackingPreferenceActionRow( title = stringResource(Res.string.trakt_more_like_this_source_title), description = stringResource(Res.string.trakt_more_like_this_source_subtitle), - value = moreLikeThisSourceValue, + value = moreLikeThisSourceLabel(effectiveRecommendationsSource), + supportingMessage = recommendationsFallback, isTablet = isTablet, - onClick = { showMoreLikeThisSourceDialog = true }, + onClick = { activePickerName = TrackingViewingPicker.MORE_LIKE_THIS.name }, ) } - statusMessage?.takeIf { it.isNotBlank() }?.let { message -> - SettingsGroupDivider(isTablet = isTablet) - TrackingInfoRow( + + when (activePicker) { + TrackingViewingPicker.CONTINUE_WATCHING -> TrackingAdaptivePicker( isTablet = isTablet, - text = message, + title = stringResource(Res.string.trakt_cw_window_title), + subtitle = stringResource(Res.string.trakt_cw_window_subtitle), + selectedValue = normalizeTraktContinueWatchingDaysCap(settingsUiState.continueWatchingDaysCap), + options = continueWatchingOptions(), + onSelected = TrackingSettingsRepository::setContinueWatchingDaysCap, + onDismiss = { activePickerName = null }, ) - } - - if (showLibrarySourceDialog) { - LibrarySourceModeDialog( - selectedSource = selectedLibrarySource, - availableSources = availableLibrarySources, - onSourceSelected = { source -> - TrackingSettingsRepository.setLibrarySourceMode(source) - statusMessage = when (source) { - LibrarySourceMode.LOCAL -> nuvioLibrarySelectedMessage - LibrarySourceMode.TRAKT -> traktLibrarySelectedMessage - LibrarySourceMode.SIMKL -> simklLibrarySelectedMessage - } - showLibrarySourceDialog = false - }, - onDismiss = { showLibrarySourceDialog = false }, - ) - } - - if (showWatchProgressDialog) { - WatchProgressSourceDialog( - selectedSource = selectedWatchProgressSource, - availableSources = availableWatchProgressSources, - onSourceSelected = { source -> - scope.launch { - val result = WatchProgressSourceCoordinator.selectSource( - profileId = ProfileRepository.activeProfileId, - source = source, - ) - statusMessage = if (result.succeeded) { - when (result.requestedSource) { - WatchProgressSource.TRAKT -> traktProgressSelectedMessage - WatchProgressSource.SIMKL -> simklProgressSelectedMessage - WatchProgressSource.NUVIO_SYNC -> nuvioProgressSelectedMessage - } - } else { - null - } - } - showWatchProgressDialog = false - }, - onDismiss = { showWatchProgressDialog = false }, - ) - } - - if (showContinueWatchingWindowDialog) { - ContinueWatchingWindowDialog( - selectedDaysCap = settingsUiState.continueWatchingDaysCap, - onDaysCapSelected = { days -> - TrackingSettingsRepository.setContinueWatchingDaysCap(days) - showContinueWatchingWindowDialog = false - }, - onDismiss = { showContinueWatchingWindowDialog = false }, - ) - } - - if (showMoreLikeThisSourceDialog) { - MoreLikeThisSourceDialog( - selectedSource = settingsUiState.moreLikeThisSource, - onSourceSelected = { source -> - TrackingSettingsRepository.setMoreLikeThisSource(source) - showMoreLikeThisSourceDialog = false - }, - onDismiss = { showMoreLikeThisSourceDialog = false }, + TrackingViewingPicker.MORE_LIKE_THIS -> TrackingAdaptivePicker( + isTablet = isTablet, + title = stringResource(Res.string.trakt_more_like_this_source_dialog_title), + subtitle = stringResource(Res.string.trakt_more_like_this_source_dialog_subtitle), + selectedValue = effectiveRecommendationsSource, + options = recommendationsSourceOptions(traktConnected), + onSelected = TrackingSettingsRepository::setMoreLikeThisSource, + onDismiss = { activePickerName = null }, ) + null -> Unit } } @Composable -private fun TrackingSettingsActionRow( +private fun TrackingPreferenceActionRow( title: String, description: String, value: String, isTablet: Boolean, onClick: () -> Unit, + supportingMessage: String? = null, + isLoading: Boolean = false, ) { - val verticalPadding = if (isTablet) 16.dp else 14.dp - val horizontalPadding = if (isTablet) 20.dp else 16.dp - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 12.dp) - .widthIn(max = if (isTablet) 560.dp else Dp.Unspecified), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -private fun TrackingInfoRow( - isTablet: Boolean, - text: String, -) { - val horizontalPadding = if (isTablet) 20.dp else 16.dp - val verticalPadding = if (isTablet) 14.dp else 12.dp - - Text( - text = text, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + val tokens = MaterialTheme.nuvio + SettingsNavigationRow( + title = title, + description = listOfNotNull( + description, + supportingMessage?.takeIf(String::isNotBlank), + ).joinToString("\n"), + isTablet = isTablet, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isLoading) { + NuvioLoadingIndicator( + color = tokens.colors.accent, + modifier = Modifier.size(16.dp), + ) + } + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.accent, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + }, + onClick = onClick, ) } @Composable -private fun librarySourceModeLabel(source: LibrarySourceMode): String = - when (source) { - LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt) - LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio) - LibrarySourceMode.SIMKL -> stringResource(Res.string.tracking_source_simkl) +private fun TrackingInlineErrorRow( + isTablet: Boolean, + message: String, + onRetry: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = if (isTablet) 20.dp else 16.dp, + vertical = if (isTablet) 14.dp else 12.dp, + ), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = message, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = tokens.colors.danger, + ) + TextButton(onClick = onRetry) { + Text(stringResource(Res.string.action_retry)) + } + } +} + +@Composable +private fun librarySourceOptions( + traktConnected: Boolean, + simklConnected: Boolean, +): List> { + val traktAvailable = isTrackingBrandAvailable(TrackingBrand.TRAKT, traktConnected, simklConnected) + val simklAvailable = isTrackingBrandAvailable(TrackingBrand.SIMKL, traktConnected, simklConnected) + return listOf( + TrackingPickerOption( + value = LibrarySourceMode.LOCAL, + title = stringResource(Res.string.trakt_library_source_nuvio), + description = stringResource(Res.string.settings_tracking_nuvio_library_description), + ), + TrackingPickerOption( + value = LibrarySourceMode.TRAKT, + title = stringResource(Res.string.trakt_library_source_trakt), + description = stringResource(Res.string.settings_tracking_trakt_library_description), + enabled = traktAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.TRAKT, traktAvailable), + ), + TrackingPickerOption( + value = LibrarySourceMode.SIMKL, + title = stringResource(Res.string.tracking_source_simkl), + description = stringResource(Res.string.settings_tracking_simkl_library_description), + enabled = simklAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.SIMKL, simklAvailable), + ), + ) +} + +@Composable +private fun watchProgressSourceOptions( + traktConnected: Boolean, + simklConnected: Boolean, +): List> { + val traktAvailable = isTrackingBrandAvailable(TrackingBrand.TRAKT, traktConnected, simklConnected) + val simklAvailable = isTrackingBrandAvailable(TrackingBrand.SIMKL, traktConnected, simklConnected) + return listOf( + TrackingPickerOption( + value = WatchProgressSource.NUVIO_SYNC, + title = stringResource(Res.string.trakt_watch_progress_source_nuvio), + description = stringResource(Res.string.settings_tracking_nuvio_progress_description), + ), + TrackingPickerOption( + value = WatchProgressSource.TRAKT, + title = stringResource(Res.string.trakt_watch_progress_source_trakt), + description = stringResource(Res.string.settings_tracking_trakt_progress_description), + enabled = traktAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.TRAKT, traktAvailable), + ), + TrackingPickerOption( + value = WatchProgressSource.SIMKL, + title = stringResource(Res.string.tracking_source_simkl), + description = stringResource(Res.string.settings_tracking_simkl_progress_description), + enabled = simklAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.SIMKL, simklAvailable), + ), + ) +} + +@Composable +private fun continueWatchingOptions(): List> = + TraktContinueWatchingDaysOptions.map { days -> + val normalizedDays = normalizeTraktContinueWatchingDaysCap(days) + TrackingPickerOption( + value = normalizedDays, + title = continueWatchingDaysCapLabel(normalizedDays), + ) } @Composable -private fun watchProgressSourceLabel(source: WatchProgressSource): String = - when (source) { - WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt) - WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio) - WatchProgressSource.SIMKL -> stringResource(Res.string.tracking_source_simkl) - } +private fun recommendationsSourceOptions( + traktConnected: Boolean, +): List> { + val traktAvailable = isTrackingBrandAvailable(TrackingBrand.TRAKT, traktConnected, simklConnected = false) + return listOf( + TrackingPickerOption( + value = MoreLikeThisSourcePreference.TMDB, + title = stringResource(Res.string.trakt_more_like_this_source_tmdb), + description = stringResource(Res.string.settings_tracking_tmdb_recommendations_description), + ), + TrackingPickerOption( + value = MoreLikeThisSourcePreference.TRAKT, + title = stringResource(Res.string.trakt_more_like_this_source_trakt), + description = stringResource(Res.string.settings_tracking_trakt_recommendations_description), + enabled = traktAvailable, + unavailableReason = trackingUnavailableReason(TrackingBrand.TRAKT, traktAvailable), + ), + ) +} @Composable -private fun moreLikeThisSourceLabel(source: MoreLikeThisSourcePreference): String = - when (source) { - MoreLikeThisSourcePreference.TRAKT -> stringResource(Res.string.trakt_more_like_this_source_trakt) - MoreLikeThisSourcePreference.TMDB -> stringResource(Res.string.trakt_more_like_this_source_tmdb) - } +private fun trackingUnavailableReason( + brand: TrackingBrand, + connected: Boolean, +): String? = if (connected) { + null +} else { + stringResource(Res.string.settings_tracking_connect_first, brand.displayName) +} + +@Composable +private fun librarySourceModeLabel(source: LibrarySourceMode): String = when (source) { + LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt) + LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio) + LibrarySourceMode.SIMKL -> stringResource(Res.string.tracking_source_simkl) +} + +@Composable +private fun watchProgressSourceLabel(source: WatchProgressSource): String = when (source) { + WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt) + WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio) + WatchProgressSource.SIMKL -> stringResource(Res.string.tracking_source_simkl) +} + +@Composable +private fun moreLikeThisSourceLabel(source: MoreLikeThisSourcePreference): String = when (source) { + MoreLikeThisSourcePreference.TRAKT -> stringResource(Res.string.trakt_more_like_this_source_trakt) + MoreLikeThisSourcePreference.TMDB -> stringResource(Res.string.trakt_more_like_this_source_tmdb) +} @Composable private fun continueWatchingDaysCapLabel(daysCap: Int): String { @@ -460,599 +539,12 @@ private fun continueWatchingDaysCapLabel(daysCap: Int): String { } } -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun LibrarySourceModeDialog( - selectedSource: LibrarySourceMode, - availableSources: List, - onSourceSelected: (LibrarySourceMode) -> Unit, - onDismiss: () -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(Res.string.trakt_library_source_dialog_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(Res.string.trakt_library_source_dialog_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - availableSources.forEach { source -> - TrackingDialogOption( - label = librarySourceModeLabel(source), - selected = source == selectedSource, - onClick = { onSourceSelected(source) }, - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(Res.string.settings_playback_dialog_close), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun WatchProgressSourceDialog( - selectedSource: WatchProgressSource, - availableSources: List, - onSourceSelected: (WatchProgressSource) -> Unit, - onDismiss: () -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(Res.string.trakt_watch_progress_dialog_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(Res.string.tracking_watch_progress_dialog_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - availableSources.forEach { source -> - TrackingDialogOption( - label = watchProgressSourceLabel(source), - selected = source == selectedSource, - onClick = { onSourceSelected(source) }, - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(Res.string.settings_playback_dialog_close), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun ContinueWatchingWindowDialog( - selectedDaysCap: Int, - onDaysCapSelected: (Int) -> Unit, - onDismiss: () -> Unit, -) { - val normalizedSelected = normalizeTraktContinueWatchingDaysCap(selectedDaysCap) - - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(Res.string.trakt_cw_window_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(Res.string.trakt_cw_window_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - TraktContinueWatchingDaysOptions.forEach { days -> - val normalizedDays = normalizeTraktContinueWatchingDaysCap(days) - TrackingDialogOption( - label = continueWatchingDaysCapLabel(days), - selected = normalizedDays == normalizedSelected, - onClick = { onDaysCapSelected(days) }, - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(Res.string.settings_playback_dialog_close), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun MoreLikeThisSourceDialog( - selectedSource: MoreLikeThisSourcePreference, - onSourceSelected: (MoreLikeThisSourcePreference) -> Unit, - onDismiss: () -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(Res.string.trakt_more_like_this_source_dialog_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(Res.string.trakt_more_like_this_source_dialog_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - listOf(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.TMDB).forEach { source -> - TrackingDialogOption( - label = moreLikeThisSourceLabel(source), - selected = source == selectedSource, - onClick = { onSourceSelected(source) }, - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(Res.string.settings_playback_dialog_close), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun TrackingDialogOption( - label: String, - selected: Boolean, - onClick: () -> Unit, -) { - val containerColor = if (selected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) +internal fun effectiveTrackingRecommendationsSource( + source: MoreLikeThisSourcePreference, + traktConnected: Boolean, +): MoreLikeThisSourcePreference = + if (source == MoreLikeThisSourcePreference.TRAKT && !traktConnected) { + MoreLikeThisSourcePreference.TMDB } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + source } - - Surface( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - shape = RoundedCornerShape(12.dp), - color = containerColor, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) - Box( - modifier = Modifier.size(24.dp), - contentAlignment = Alignment.Center, - ) { - if (selected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } - } - } - } -} - -@Composable -private fun TrackingIntro(isTablet: Boolean) { - val horizontalPadding = if (isTablet) 20.dp else 16.dp - val verticalPadding = if (isTablet) 18.dp else 16.dp - - Text( - text = stringResource(Res.string.settings_tracking_intro_description), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} - -private enum class ConnectionCardMode { - DISCONNECTED, - AWAITING_APPROVAL, - CONNECTED, -} - -@Composable -private fun TraktConnectionCard( - isTablet: Boolean, - uiState: TraktAuthUiState, -) { - ProviderConnectionCard( - isTablet = isTablet, - mode = when (uiState.mode) { - TraktConnectionMode.DISCONNECTED -> ConnectionCardMode.DISCONNECTED - TraktConnectionMode.AWAITING_APPROVAL -> ConnectionCardMode.AWAITING_APPROVAL - TraktConnectionMode.CONNECTED -> ConnectionCardMode.CONNECTED - }, - credentialsConfigured = uiState.credentialsConfigured, - isLoading = uiState.isLoading, - connectedLabel = stringResource( - Res.string.settings_trakt_connected_as, - uiState.username ?: stringResource(Res.string.settings_trakt_default_user), - ), - connectedDescription = stringResource(Res.string.settings_trakt_save_actions_description), - signInDescription = stringResource(Res.string.settings_trakt_sign_in_description), - finishSignInLabel = stringResource(Res.string.settings_trakt_finish_sign_in), - approvalDescription = stringResource(Res.string.settings_trakt_approval_redirect), - connectLabel = stringResource(Res.string.settings_trakt_connect), - openLoginLabel = stringResource(Res.string.settings_trakt_open_login), - disconnectLabel = stringResource(Res.string.settings_trakt_disconnect), - missingCredentialsMessage = stringResource(Res.string.settings_trakt_missing_credentials), - statusMessage = uiState.statusMessage, - errorMessage = uiState.errorMessage, - onConnectRequested = TraktAuthRepository::onConnectRequested, - onResumeAuthorization = { - TraktAuthRepository.pendingAuthorizationUrl() - ?: TraktAuthRepository.onConnectRequested() - }, - onCancelAuthorization = TraktAuthRepository::onCancelAuthorization, - onDisconnect = TraktAuthRepository::onDisconnectRequested, - ) -} - -@Composable -private fun SimklConnectionCard( - isTablet: Boolean, - uiState: SimklAuthUiState, -) { - val syncState by remember { - SimklSyncRepository.ensureLoaded() - SimklSyncRepository.state - }.collectAsStateWithLifecycle() - val scope = rememberCoroutineScope() - var showSyncInfo by rememberSaveable { mutableStateOf(false) } - - ProviderConnectionCard( - isTablet = isTablet, - mode = when (uiState.mode) { - SimklConnectionMode.DISCONNECTED -> ConnectionCardMode.DISCONNECTED - SimklConnectionMode.AWAITING_APPROVAL -> ConnectionCardMode.AWAITING_APPROVAL - SimklConnectionMode.CONNECTED -> ConnectionCardMode.CONNECTED - }, - credentialsConfigured = uiState.credentialsConfigured, - isLoading = uiState.isLoading, - connectedLabel = stringResource( - Res.string.settings_simkl_connected_as, - uiState.username ?: stringResource(Res.string.settings_simkl_default_user), - ), - connectedDescription = stringResource(Res.string.settings_simkl_connected_description), - signInDescription = stringResource(Res.string.settings_simkl_sign_in_description), - finishSignInLabel = stringResource(Res.string.settings_simkl_finish_sign_in), - approvalDescription = stringResource(Res.string.settings_tracking_approval_redirect), - connectLabel = stringResource(Res.string.settings_simkl_connect), - openLoginLabel = stringResource(Res.string.settings_simkl_open_login), - disconnectLabel = stringResource(Res.string.settings_simkl_disconnect), - syncLabel = stringResource(Res.string.settings_simkl_sync_now), - infoLabel = stringResource(Res.string.settings_simkl_sync_info_action), - isSyncing = syncState.isLoading, - missingCredentialsMessage = stringResource(Res.string.settings_simkl_missing_credentials), - errorMessage = simklErrorMessage(uiState.error) ?: syncState.errorMessage, - websiteLabel = stringResource(Res.string.settings_simkl_visit), - websiteUrl = SIMKL_WEBSITE_URL, - onConnectRequested = SimklAuthRepository::onConnectRequested, - onResumeAuthorization = { - SimklAuthRepository.pendingAuthorizationUrl() - ?: SimklAuthRepository.onConnectRequested() - }, - onCancelAuthorization = SimklAuthRepository::onCancelAuthorization, - onSyncRequested = { - scope.launch { - SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) - } - }, - onInfoRequested = { showSyncInfo = true }, - onDisconnect = SimklAuthRepository::onDisconnectRequested, - ) - - if (showSyncInfo) { - SimklSyncInfoDialog(onDismiss = { showSyncInfo = false }) - } -} - -@Composable -private fun ProviderConnectionCard( - isTablet: Boolean, - mode: ConnectionCardMode, - credentialsConfigured: Boolean, - isLoading: Boolean, - connectedLabel: String, - connectedDescription: String, - signInDescription: String, - finishSignInLabel: String, - approvalDescription: String, - connectLabel: String, - openLoginLabel: String, - disconnectLabel: String, - syncLabel: String? = null, - infoLabel: String? = null, - isSyncing: Boolean = false, - missingCredentialsMessage: String, - statusMessage: String? = null, - errorMessage: String? = null, - websiteLabel: String? = null, - websiteUrl: String? = null, - onConnectRequested: () -> String?, - onResumeAuthorization: () -> String?, - onCancelAuthorization: () -> Unit, - onSyncRequested: (() -> Unit)? = null, - onInfoRequested: (() -> Unit)? = null, - onDisconnect: () -> Unit, -) { - val uriHandler = LocalUriHandler.current - val horizontalPadding = if (isTablet) 20.dp else 16.dp - val verticalPadding = if (isTablet) 18.dp else 16.dp - val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser) - var browserError by rememberSaveable { mutableStateOf(null) } - - fun openUrl(url: String?) { - if (url.isNullOrBlank()) return - browserError = null - runCatching { uriHandler.openUri(url) } - .onFailure { error -> browserError = error.message ?: failedOpenBrowserMessage } - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - when (mode) { - ConnectionCardMode.CONNECTED -> { - Text( - text = connectedLabel, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = connectedDescription, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (syncLabel != null && onSyncRequested != null) { - Button( - onClick = onSyncRequested, - enabled = !isLoading && !isSyncing, - ) { - if (isSyncing) { - NuvioLoadingIndicator( - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(18.dp), - ) - } else { - Text(syncLabel) - } - } - } - if (infoLabel != null && onInfoRequested != null) { - TextButton(onClick = onInfoRequested) { - Icon( - imageVector = Icons.Rounded.Info, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.size(8.dp)) - Text(infoLabel) - } - } - Button( - onClick = onDisconnect, - enabled = !isLoading && !isSyncing, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - if (isLoading) { - NuvioLoadingIndicator( - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(18.dp), - ) - } else { - Text(disconnectLabel) - } - } - } - - ConnectionCardMode.AWAITING_APPROVAL -> { - Text( - text = finishSignInLabel, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = approvalDescription, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button( - onClick = { openUrl(onResumeAuthorization()) }, - enabled = !isLoading, - ) { - Text(openLoginLabel) - } - Button( - onClick = onCancelAuthorization, - enabled = !isLoading, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Text(stringResource(Res.string.action_cancel)) - } - } - - ConnectionCardMode.DISCONNECTED -> { - Text( - text = signInDescription, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button( - onClick = { openUrl(onConnectRequested()) }, - enabled = credentialsConfigured && !isLoading, - ) { - if (isLoading) { - NuvioLoadingIndicator( - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(18.dp), - ) - } else { - Text(connectLabel) - } - } - if (!credentialsConfigured) { - Text( - text = missingCredentialsMessage, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - } - - if (!websiteLabel.isNullOrBlank() && !websiteUrl.isNullOrBlank()) { - TextButton(onClick = { openUrl(websiteUrl) }) { - Text(websiteLabel) - } - } - statusMessage?.takeIf { it.isNotBlank() }?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - errorMessage?.takeIf { it.isNotBlank() }?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - browserError?.let { - Text( - text = failedOpenBrowserMessage, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } -} - -@Composable -private fun simklErrorMessage(error: SimklAuthError?): String? = - when (error) { - null, SimklAuthError.MISSING_CLIENT_ID -> null - SimklAuthError.INVALID_CALLBACK, - SimklAuthError.INVALID_CALLBACK_STATE - -> stringResource(Res.string.settings_simkl_invalid_callback) - - SimklAuthError.AUTHORIZATION_EXPIRED -> - stringResource(Res.string.settings_simkl_authorization_expired) - - SimklAuthError.TOKEN_EXCHANGE_FAILED, - SimklAuthError.INVALID_TOKEN_RESPONSE - -> stringResource(Res.string.settings_simkl_sign_in_failed) - - SimklAuthError.AUTHORIZATION_REVOKED -> - stringResource(Res.string.settings_simkl_authorization_revoked) - } - -private const val SIMKL_WEBSITE_URL = "https://simkl.com" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.kt new file mode 100644 index 000000000..01d1a8575 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.features.simkl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter + +enum class SimklBrandAsset { + Glyph, + Wordmark, +} + +@Composable +expect fun simklBrandPainter(asset: SimklBrandAsset): Painter diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/settings/TrackingSettingsPresentationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/settings/TrackingSettingsPresentationTest.kt new file mode 100644 index 000000000..189a5a59b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/settings/TrackingSettingsPresentationTest.kt @@ -0,0 +1,64 @@ +package com.nuvio.app.features.settings + +import com.nuvio.app.features.simkl.SimklConnectionMode +import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference +import com.nuvio.app.features.trakt.TraktConnectionMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TrackingSettingsPresentationTest { + @Test + fun `provider connection modes map to matching card modes`() { + assertEquals( + TrackingConnectionCardMode.DISCONNECTED, + TraktConnectionMode.DISCONNECTED.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.AWAITING_APPROVAL, + TraktConnectionMode.AWAITING_APPROVAL.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.CONNECTED, + TraktConnectionMode.CONNECTED.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.DISCONNECTED, + SimklConnectionMode.DISCONNECTED.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.AWAITING_APPROVAL, + SimklConnectionMode.AWAITING_APPROVAL.toTrackingConnectionCardMode(), + ) + assertEquals( + TrackingConnectionCardMode.CONNECTED, + SimklConnectionMode.CONNECTED.toTrackingConnectionCardMode(), + ) + } + + @Test + fun `remote brands are available only while their provider is connected`() { + assertTrue(isTrackingBrandAvailable(TrackingBrand.NUVIO, false, false)) + assertTrue(isTrackingBrandAvailable(TrackingBrand.TMDB, false, false)) + assertFalse(isTrackingBrandAvailable(TrackingBrand.TRAKT, false, true)) + assertTrue(isTrackingBrandAvailable(TrackingBrand.TRAKT, true, false)) + assertFalse(isTrackingBrandAvailable(TrackingBrand.SIMKL, true, false)) + assertTrue(isTrackingBrandAvailable(TrackingBrand.SIMKL, false, true)) + } + + @Test + fun `Trakt recommendations fall back visually without rewriting preference`() { + val stored = MoreLikeThisSourcePreference.TRAKT + + assertEquals( + MoreLikeThisSourcePreference.TMDB, + effectiveTrackingRecommendationsSource(stored, traktConnected = false), + ) + assertEquals( + MoreLikeThisSourcePreference.TRAKT, + effectiveTrackingRecommendationsSource(stored, traktConnected = true), + ) + assertEquals(MoreLikeThisSourcePreference.TRAKT, stored) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.ios.kt new file mode 100644 index 000000000..156f6220c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/simkl/SimklBrandPainter.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.simkl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.simkl_logo_glyph +import nuvio.composeapp.generated.resources.simkl_logo_wordmark +import org.jetbrains.compose.resources.painterResource + +@Composable +actual fun simklBrandPainter(asset: SimklBrandAsset): Painter = + when (asset) { + SimklBrandAsset.Glyph -> painterResource(Res.drawable.simkl_logo_glyph) + SimklBrandAsset.Wordmark -> painterResource(Res.drawable.simkl_logo_wordmark) + }