feat(simkl): explain sync cadence

This commit is contained in:
tapframe 2026-07-22 22:24:10 +05:30
parent b4eba6e0c5
commit b817eb5b14
5 changed files with 138 additions and 1 deletions

View file

@ -1139,6 +1139,12 @@
<string name="settings_simkl_authorization_expired">The Simkl sign-in request expired. Start again.</string>
<string name="settings_simkl_sign_in_failed">Could not complete Simkl sign in. Please try again.</string>
<string name="settings_simkl_sync_now">Sync now</string>
<string name="settings_simkl_sync_info_action">How syncing works</string>
<string name="settings_simkl_sync_info_title">How Simkl syncing works</string>
<string name="settings_simkl_sync_info_description">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.</string>
<string name="settings_simkl_sync_info_activity">Each refresh first checks whether Simkl reports any changes and downloads only the updates. This follows Simkls API rules, reduces unnecessary data use, and helps keep the service stable.</string>
<string name="settings_simkl_sync_info_manual">Changes made in Nuvio are sent to Simkl immediately. Use Sync now whenever you want Nuvio to check for remote changes sooner.</string>
<string name="settings_simkl_sync_info_docs">Read the Simkl sync guide</string>
<string name="settings_simkl_authorization_revoked">Simkl authorization expired or was revoked. Connect again.</string>
<string name="tracking_source_simkl">Simkl</string>
<string name="tracking_library_source_simkl_selected">Simkl watchlist selected</string>

View file

@ -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"

View file

@ -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,

View file

@ -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,

View file

@ -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,