Add refresh button to tracker for new profile info (#3828)

* Add refresh button to tracker for new profile info

As-is, allows a user to manually refresh their current user data from
the tracker in order to update Mihon's knowledge of remote settings
(e.g. nickname changes, AL/MB/Kitsu score types, etc.).

* Catch errors to prevent app crashing

Surfaces errors as toasts in the same way library title refreshes do.

* spotless

* [skip ci] Remove leftover debugging logs

* Fix Kitsu user attribute access

* Spotless

* Lowercase Kitsu score type on refresh

Maybe prevent needing another #3887 for this.

* Center tracker vertically name if not logged in

Looks much better than the name being stuck at the top with empty
space below it.

* Don't show Refresh button for Enhanced trackers

* [skip ci] Add changelog entry
This commit is contained in:
MajorTanya
2026-09-20 00:35:25 +06:00
committed by GitHub
parent 504ec2afae
commit 01e3f8c605
18 changed files with 199 additions and 17 deletions
+1
View File
@@ -16,6 +16,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Allow `id:` to search for slugs on Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3792](https://github.com/mihonapp/mihon/pull/3792))
- Add support for using the user's chosen rating system for Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3818](https://github.com/mihonapp/mihon/pull/3818))
- Add support for the Year, Month, and Day fields in ComicInfo.xml files for chapter dating ([@MajorTanya](https://github.com/MajorTanya)) ([#3967](https://github.com/mihonapp/mihon/pull/3967))
- Add refresh buttons to trackers in Settings to update displayed usernames (all trackers) & rating systems (where supported) ([@MajorTanya](https://github.com/MajorTanya)) ([#3828](https://github.com/mihonapp/mihon/pull/3828))
### Improved
- Show updates and upcoming filter icon as active for categories ([@Secozzi](https://github.com/Secozzi)) ([#3772](https://github.com/mihonapp/mihon/pull/3772))
@@ -9,12 +9,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.more.settings.widget.EditTextPreferenceWidget
import eu.kanade.presentation.more.settings.widget.InfoWidget
@@ -26,7 +28,11 @@ import eu.kanade.presentation.more.settings.widget.SwitchPreferenceWidget
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
import eu.kanade.presentation.more.settings.widget.TitleFontSize
import eu.kanade.presentation.more.settings.widget.TrackingPreferenceWidget
import eu.kanade.tachiyomi.data.track.RefreshResult
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.launch
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.BaseSliderItem
import tachiyomi.presentation.core.util.collectAsState
@@ -169,12 +175,28 @@ internal fun PreferenceItem(
)
}
is Preference.PreferenceItem.TrackerPreference -> {
val context = LocalContext.current
LaunchedEffect(item.tracker) {
item.tracker.refreshResultFlow.collect {
when (it) {
RefreshResult.Success -> context.toast(
context.stringResource(MR.strings.refresh_tracker_success, item.tracker.name),
)
is RefreshResult.Error -> context.toast(
context.stringResource(MR.strings.refresh_tracker_error, item.tracker.name, it.msg),
)
}
}
}
val isLoggedIn by item.tracker.let { tracker ->
tracker.isLoggedInFlow.collectAsState(tracker.isLoggedIn)
}
val isRefreshing by item.tracker.isRefreshingFlow.collectAsState()
TrackingPreferenceWidget(
tracker = item.tracker,
isLoggedIn = isLoggedIn,
isRefreshing = isRefreshing,
onClick = { if (isLoggedIn) item.logout() else item.login() },
)
}
@@ -7,10 +7,13 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -19,9 +22,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.more.settings.LocalPreferenceHighlighted
import eu.kanade.presentation.track.components.TrackLogoIcon
import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.Done
import mihon.icons.materialsymbols.rounded.Refresh
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
@@ -30,16 +37,26 @@ fun TrackingPreferenceWidget(
modifier: Modifier = Modifier,
tracker: Tracker,
isLoggedIn: Boolean,
isRefreshing: Boolean,
onClick: (() -> Unit)? = null,
) {
val highlighted = LocalPreferenceHighlighted.current
val scope = rememberCoroutineScope { Dispatchers.IO }
Box(modifier = Modifier.highlightBackground(highlighted)) {
Row(
modifier = modifier
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
.fillMaxWidth()
.padding(horizontal = PrefsHorizontalPadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.clickable(
enabled = onClick != null,
onClick = { onClick?.invoke() },
)
.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
TrackLogoIcon(tracker)
Column(
@@ -64,7 +81,31 @@ fun TrackingPreferenceWidget(
)
}
}
}
if (isLoggedIn) {
if (tracker !is EnhancedTracker) {
IconButton(
enabled = !isRefreshing,
onClick = {
scope.launch { tracker.refreshUser() }
},
) {
if (!isRefreshing) {
Icon(
imageVector = MaterialSymbols.Rounded.Refresh,
modifier = Modifier
.padding(4.dp)
.size(32.dp),
contentDescription = stringResource(MR.strings.refresh_tracker_profile),
)
} else {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
}
}
}
Icon(
imageVector = MaterialSymbols.Rounded.Done,
modifier = Modifier
@@ -8,7 +8,12 @@ import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import logcat.LogPriority
import mihon.app.di.appGraph
@@ -70,6 +75,29 @@ abstract class BaseTracker(
}
}
final override val isRefreshingFlow: StateFlow<Boolean>
field: MutableStateFlow<Boolean> = MutableStateFlow(false)
final override val refreshResultFlow: SharedFlow<RefreshResult>
field: MutableSharedFlow<RefreshResult> = MutableSharedFlow(extraBufferCapacity = 1)
final override suspend fun refreshUser() {
isRefreshingFlow.value = true
try {
updateUserConfig()
refreshResultFlow.emit(RefreshResult.Success)
} catch (e: Exception) {
if (e is CancellationException) throw e
logcat(LogPriority.ERROR, e) { "Failed to update user config id=$id" }
refreshResultFlow.emit(RefreshResult.Error(e.message ?: "Failed with unknown error"))
} finally {
isRefreshingFlow.value = false
}
}
// does the actual remote calls shielded from outside access to guarantee proper refresh flow setting
protected abstract suspend fun updateUserConfig()
override fun getUsername() = trackPreferences.trackUsername(this).get()
override fun getDisplayUsername(): String = trackPreferences.trackDisplayUsername(this).get()
@@ -151,3 +179,8 @@ abstract class BaseTracker(
}
}
}
sealed interface RefreshResult {
data object Success : RefreshResult
data class Error(val msg: String) : RefreshResult
}
@@ -6,6 +6,8 @@ import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import okhttp3.OkHttpClient
import tachiyomi.domain.track.model.Track as DomainTrack
@@ -57,10 +59,16 @@ interface Tracker {
@CallSuper
fun logout()
suspend fun refreshUser()
val isLoggedIn: Boolean
val isLoggedInFlow: Flow<Boolean>
val isRefreshingFlow: StateFlow<Boolean>
val refreshResultFlow: SharedFlow<RefreshResult>
fun getUsername(): String
fun getPassword(): String
@@ -214,6 +214,12 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
return track
}
override suspend fun updateUserConfig() {
val currentUser = api.getCurrentUser()
scorePreference.set(currentUser.mediaListOptions.scoreFormat)
saveDisplayUsername(currentUser.name)
}
override suspend fun login(username: String, password: String) = login(password)
suspend fun login(token: String) {
@@ -120,6 +120,11 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
}
}
override suspend fun updateUserConfig() {
val currentUser = api.getCurrentUser()
saveDisplayUsername(currentUser.nickname?.takeIf { it.isNotBlank() } ?: currentUser.username)
}
fun saveToken(oauth: BGMOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oauth))
}
@@ -146,6 +146,11 @@ class Hikka(id: Long) : BaseTracker(id, "Hikka"), DeletableTracker {
return track
}
override suspend fun updateUserConfig() {
val currentUser = api.getCurrentUser()
saveDisplayUsername(currentUser.username)
}
override suspend fun login(username: String, password: String) = login(password)
suspend fun login(reference: String) {
@@ -140,4 +140,6 @@ class Kavita(id: Long) : BaseTracker(id, "Kavita"), EnhancedTracker {
}
authentications = oauth
}
override suspend fun updateUserConfig() = Unit
}
@@ -196,6 +196,19 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
saveCredentials(username, currentUser.id)
}
override suspend fun updateUserConfig() {
val currentUser = api.getCurrentUser()
val ratingSystem = currentUser.ratingSystem
if (ratingSystem.lowercase() in listOf(RATING_SIMPLE, RATING_REGULAR, RATING_ADVANCED)) {
scorePreference.set(ratingSystem.lowercase())
} else {
logcat(LogPriority.ERROR) { "Unsupported Kitsu score type: $ratingSystem" }
scorePreference.set(RATING_ADVANCED)
}
saveDisplayUsername(currentUser.profile.name)
}
override fun logout() {
super.logout()
interceptor.newAuth(null)
@@ -107,4 +107,6 @@ class Komga(id: Long) : BaseTracker(id, "Komga"), EnhancedTracker {
} else {
null
}
override suspend fun updateUserConfig() = Unit
}
@@ -143,6 +143,20 @@ class MangaBaka(id: Long) : BaseTracker(id, "MangaBaka"), DeletableTracker {
}
}
override suspend fun updateUserConfig() {
val currentUser = api.getCurrentUser()
val scoreType = when (currentUser.ratingSteps) {
1 -> STEP_1
5 -> STEP_5
10 -> STEP_10
20 -> STEP_20
25 -> STEP_25
else -> throw Exception("Unknown score step size ${currentUser.ratingSteps}")
}
scorePreference.set(scoreType)
saveDisplayUsername(currentUser.nickname ?: currentUser.preferredUsername ?: currentUser.id)
}
fun saveToken(oauth: MangaBakaOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oauth))
}
@@ -124,6 +124,11 @@ class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker
saveCredentials(authenticated.uid.toString(), authenticated.sessionToken)
}
override suspend fun updateUserConfig() {
val currentUser = api.getCurrentUser()
saveDisplayUsername(currentUser.username)
}
fun restoreSession(): String? {
return trackPreferences.trackPassword(this).get().ifBlank { null }
}
@@ -144,6 +144,11 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker {
}
}
override suspend fun updateUserConfig() {
val username = api.getCurrentUser()
saveDisplayUsername(username)
}
override fun logout() {
super.logout()
trackPreferences.trackToken(this).delete()
@@ -136,6 +136,11 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
}
}
override suspend fun updateUserConfig() {
val currentUser = api.getCurrentUser()
saveDisplayUsername(currentUser.nickname)
}
fun saveToken(oauth: SMOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oauth))
}
@@ -108,4 +108,6 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
val preferences = api.sourcePreferences()
return preferences.getBoolean(TRACKER_DELETE_KEY, TRACKER_DELETE_DEFAULT)
}
override suspend fun updateUserConfig() = Unit
}
@@ -2,9 +2,15 @@ package eu.kanade.test
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.RefreshResult
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.flowOf
import okhttp3.OkHttpClient
import tachiyomi.domain.track.model.Track
@@ -17,6 +23,8 @@ data class DummyTracker(
override val supportsPrivateTracking: Boolean = false,
override val isLoggedIn: Boolean = false,
override val isLoggedInFlow: Flow<Boolean> = flowOf(false),
override val isRefreshingFlow: StateFlow<Boolean> = MutableStateFlow(false),
override val refreshResultFlow: SharedFlow<RefreshResult> = MutableSharedFlow<RefreshResult>().asSharedFlow(),
val valLogo: Int = R.drawable.brand_anilist,
val valStatuses: List<Long> = (1L..6L).toList(),
val valReadingStatus: Long = 1L,
@@ -75,6 +83,8 @@ data class DummyTracker(
track: eu.kanade.tachiyomi.data.database.models.Track,
): eu.kanade.tachiyomi.data.database.models.Track = track
override suspend fun refreshUser() = Unit
override suspend fun login(username: String, password: String) = Unit
override fun logout() = Unit
@@ -580,6 +580,9 @@
<string name="enhanced_services_not_installed">Available but source not installed: %s</string>
<string name="enhanced_tracking_info">Provides enhanced features for specific sources. Entries are automatically tracked when added to your library.</string>
<string name="track_activity_name">Tracker login</string>
<string name="refresh_tracker_profile">Refresh tracker profile</string>
<string name="refresh_tracker_success">%s profile refreshed</string>
<string name="refresh_tracker_error">%1$s refresh error: %2$s</string>
<!-- Browse section -->
<string name="pref_hide_in_library_items">Hide entries already in library</string>