diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index e6315c3c9..966f1b7c3 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1139,6 +1139,12 @@ The Simkl sign-in request expired. Start again. Could not complete Simkl sign in. Please try again. Sync now + How syncing works + How Simkl syncing works + Nuvio automatically checks Simkl at most once every %1$d minutes. Changes made through another app or the Simkl website may take that long to appear. + Each refresh first checks whether Simkl reports any changes and downloads only the updates. This follows Simkl’s API rules, reduces unnecessary data use, and helps keep the service stable. + Changes made in Nuvio are sent to Simkl immediately. Use Sync now whenever you want Nuvio to check for remote changes sooner. + Read the Simkl sync guide Simkl authorization expired or was revoked. Connect again. Simkl Simkl watchlist selected diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt new file mode 100644 index 000000000..f5b449df5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SimklSyncInfoDialog.kt @@ -0,0 +1,106 @@ +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.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.simkl.SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_activity +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_description +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_docs +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_manual +import nuvio.composeapp.generated.resources.settings_simkl_sync_info_title +import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser +import org.jetbrains.compose.resources.stringResource + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun SimklSyncInfoDialog(onDismiss: () -> Unit) { + val uriHandler = LocalUriHandler.current + var browserError by rememberSaveable { mutableStateOf(false) } + + 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.settings_simkl_sync_info_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource( + Res.string.settings_simkl_sync_info_description, + SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.settings_simkl_sync_info_activity), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.settings_simkl_sync_info_manual), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (browserError) { + Text( + text = stringResource(Res.string.settings_trakt_failed_open_browser), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = { + browserError = false + runCatching { uriHandler.openUri(SIMKL_SYNC_GUIDE_URL) } + .onFailure { browserError = true } + }, + ) { + Text(stringResource(Res.string.settings_simkl_sync_info_docs)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_close)) + } + } + } + } + } +} + +private const val SIMKL_SYNC_GUIDE_URL = "https://api.simkl.org/guides/sync" 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 87f386044..f7e39baef 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 @@ -15,6 +15,7 @@ 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 @@ -99,6 +100,7 @@ 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 @@ -791,6 +793,7 @@ private fun SimklConnectionCard( SimklSyncRepository.state }.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + var showSyncInfo by rememberSaveable { mutableStateOf(false) } ProviderConnectionCard( isTablet = isTablet, @@ -813,6 +816,7 @@ private fun SimklConnectionCard( 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, @@ -829,8 +833,13 @@ private fun SimklConnectionCard( SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED) } }, + onInfoRequested = { showSyncInfo = true }, onDisconnect = SimklAuthRepository::onDisconnectRequested, ) + + if (showSyncInfo) { + SimklSyncInfoDialog(onDismiss = { showSyncInfo = false }) + } } @Composable @@ -848,6 +857,7 @@ private fun ProviderConnectionCard( openLoginLabel: String, disconnectLabel: String, syncLabel: String? = null, + infoLabel: String? = null, isSyncing: Boolean = false, missingCredentialsMessage: String, statusMessage: String? = null, @@ -858,6 +868,7 @@ private fun ProviderConnectionCard( onResumeAuthorization: () -> String?, onCancelAuthorization: () -> Unit, onSyncRequested: (() -> Unit)? = null, + onInfoRequested: (() -> Unit)? = null, onDisconnect: () -> Unit, ) { val uriHandler = LocalUriHandler.current @@ -907,6 +918,17 @@ private fun ProviderConnectionCard( } } } + 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt index c5ab7fe11..b40b5fd58 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicy.kt @@ -5,7 +5,9 @@ import kotlinx.atomicfu.atomic import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -internal const val SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS = 15L * 60L * 1_000L +internal const val SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES = 15 +internal const val SIMKL_AUTOMATIC_REFRESH_INTERVAL_MS = + SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES * 60L * 1_000L internal fun shouldRunSimklRefresh( intent: TrackingRefreshIntent, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt index 1f7098b4e..849c3f8ee 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklRefreshPolicyTest.kt @@ -26,6 +26,7 @@ class SimklRefreshPolicyTest { fun `automatic refresh is throttled for fifteen minutes`() { val lastCheckedAt = 1_000L + assertEquals(15, SIMKL_AUTOMATIC_REFRESH_INTERVAL_MINUTES) assertFalse( shouldRunSimklRefresh( intent = TrackingRefreshIntent.AUTOMATIC,