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,