mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat(settings): add app update settings action
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package hu.bbara.purefin.ui.screen.settings
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -24,6 +25,7 @@ import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -31,6 +33,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -39,7 +42,6 @@ import hu.bbara.purefin.core.settings.BooleanSetting
|
||||
import hu.bbara.purefin.core.settings.DropdownSetting
|
||||
import hu.bbara.purefin.core.settings.RangeSetting
|
||||
import hu.bbara.purefin.core.settings.SettingOption
|
||||
import hu.bbara.purefin.core.settings.SettingsOptions
|
||||
import hu.bbara.purefin.core.settings.StringSetting
|
||||
import hu.bbara.purefin.core.settings.VoidSetting
|
||||
import java.util.Locale
|
||||
@@ -50,6 +52,15 @@ fun TvSettingsScreen(
|
||||
onBack: () -> Unit = viewModel::onBack,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val settingGroups by viewModel.settingGroups.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(viewModel, context) {
|
||||
viewModel.snackbarMessages.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
@@ -72,7 +83,7 @@ fun TvSettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SettingsOptions.groups.forEach { group ->
|
||||
settingGroups.forEach { group ->
|
||||
group.title?.let { title ->
|
||||
item {
|
||||
Text(
|
||||
@@ -135,7 +146,7 @@ private fun TvSettingOptionItem(
|
||||
is VoidSetting -> {
|
||||
TvVoidSettingItem(
|
||||
title = option.title,
|
||||
onClick = {}
|
||||
onClick = { viewModel.onClick(option) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,14 @@ import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -21,7 +25,6 @@ import hu.bbara.purefin.core.settings.BooleanSetting
|
||||
import hu.bbara.purefin.core.settings.DropdownSetting
|
||||
import hu.bbara.purefin.core.settings.RangeSetting
|
||||
import hu.bbara.purefin.core.settings.SettingOption
|
||||
import hu.bbara.purefin.core.settings.SettingsOptions
|
||||
import hu.bbara.purefin.core.settings.StringSetting
|
||||
import hu.bbara.purefin.core.settings.VoidSetting
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||
@@ -37,10 +40,20 @@ fun SettingsScreen(
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val settingGroups by viewModel.settingGroups.collectAsState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.snackbarMessages.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
SettingsTopBar(onBack = viewModel::onBack)
|
||||
}
|
||||
@@ -59,7 +72,7 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SettingsOptions.groups.forEach { group ->
|
||||
settingGroups.forEach { group ->
|
||||
group.title?.let { title ->
|
||||
item {
|
||||
Text(
|
||||
@@ -122,7 +135,7 @@ private fun SettingOptionItem(
|
||||
is VoidSetting -> {
|
||||
VoidSettingItem(
|
||||
title = option.title,
|
||||
onClick = {}
|
||||
onClick = { viewModel.onClick(option) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import hu.bbara.purefin.core.data.HomeRepository
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.feature.update.AppUpdateController
|
||||
import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater
|
||||
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
@@ -40,6 +41,7 @@ class AppViewModel @Inject constructor(
|
||||
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
private val appUpdateController: AppUpdateController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
@@ -239,6 +241,10 @@ class AppViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
homeRepository.refreshHomeData()
|
||||
appUpdateController.checkForUpdates(
|
||||
showUpToDateMessage = false,
|
||||
showFailureMessage = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Refresh is best-effort; don't crash on failure
|
||||
}
|
||||
@@ -255,6 +261,10 @@ class AppViewModel @Inject constructor(
|
||||
_isRefreshing.value = true
|
||||
try {
|
||||
homeRepository.refreshHomeData()
|
||||
appUpdateController.checkForUpdates(
|
||||
showUpToDateMessage = false,
|
||||
showFailureMessage = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Refresh is best-effort; don't crash on failure
|
||||
} finally {
|
||||
|
||||
@@ -7,8 +7,20 @@ import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.settings.BooleanSetting
|
||||
import hu.bbara.purefin.core.settings.DropdownSetting
|
||||
import hu.bbara.purefin.core.settings.RangeSetting
|
||||
import hu.bbara.purefin.core.settings.SettingGroup
|
||||
import hu.bbara.purefin.core.settings.SettingsGroupProvider
|
||||
import hu.bbara.purefin.core.settings.SettingsRepository
|
||||
import hu.bbara.purefin.core.settings.SettingsOptions
|
||||
import hu.bbara.purefin.core.settings.StringSetting
|
||||
import hu.bbara.purefin.core.settings.VoidSetting
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -16,8 +28,30 @@ import javax.inject.Inject
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
settingsGroupProviders: Set<@JvmSuppressWildcards SettingsGroupProvider>,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _snackbarMessages = MutableSharedFlow<String>()
|
||||
val snackbarMessages: SharedFlow<String> = _snackbarMessages.asSharedFlow()
|
||||
|
||||
private val dynamicSettingGroups = settingsGroupProviders
|
||||
.map { it.settingGroups }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { groupFlows ->
|
||||
combine(groupFlows) { groups -> groups.toList().flatten() }
|
||||
}
|
||||
?: flowOf(emptyList())
|
||||
|
||||
val settingGroups: StateFlow<List<SettingGroup>> = dynamicSettingGroups
|
||||
.combine(flowOf(SettingsOptions.groups)) { dynamicGroups, staticGroups ->
|
||||
staticGroups + dynamicGroups
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = SettingsOptions.groups
|
||||
)
|
||||
|
||||
fun value(option: RangeSetting) = settingsRepository.value(option)
|
||||
|
||||
fun value(option: BooleanSetting) = settingsRepository.value(option)
|
||||
@@ -50,6 +84,16 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun onClick(option: VoidSetting) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
option.onClick()
|
||||
} catch (e: Exception) {
|
||||
_snackbarMessages.emit(e.message ?: "Setting action failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package hu.bbara.purefin.core.feature.update
|
||||
|
||||
import hu.bbara.purefin.core.settings.SettingGroup
|
||||
import hu.bbara.purefin.core.settings.SettingsGroupProvider
|
||||
import hu.bbara.purefin.core.settings.VoidSetting
|
||||
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.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AppUpdateController @Inject constructor(
|
||||
private val appUpdateRepository: AppUpdateRepository,
|
||||
private val appUpdateInstaller: AppUpdateInstaller
|
||||
) : SettingsGroupProvider {
|
||||
private val _isCheckingForUpdates = MutableStateFlow(false)
|
||||
val isCheckingForUpdates: StateFlow<Boolean> = _isCheckingForUpdates.asStateFlow()
|
||||
|
||||
private val _availableUpdate = MutableStateFlow<AppUpdateInfo?>(null)
|
||||
val availableUpdate: StateFlow<AppUpdateInfo?> = _availableUpdate.asStateFlow()
|
||||
|
||||
private val _snackbarMessages = MutableSharedFlow<String>()
|
||||
val snackbarMessages: SharedFlow<String> = _snackbarMessages.asSharedFlow()
|
||||
|
||||
private val checkMutex = Mutex()
|
||||
private val installMutex = Mutex()
|
||||
|
||||
override val settingGroups: Flow<List<SettingGroup>> = availableUpdate
|
||||
.map { update ->
|
||||
if (update == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(
|
||||
SettingGroup(
|
||||
title = "App",
|
||||
options = listOf(installUpdateSetting(update))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkForUpdates(
|
||||
showUpToDateMessage: Boolean = true,
|
||||
showFailureMessage: Boolean = true
|
||||
) {
|
||||
if (!checkMutex.tryLock()) {
|
||||
return
|
||||
}
|
||||
|
||||
_isCheckingForUpdates.value = true
|
||||
try {
|
||||
val update = appUpdateRepository.checkForUpdate()
|
||||
_availableUpdate.value = update
|
||||
if (update == null && showUpToDateMessage) {
|
||||
_snackbarMessages.emit("Purefin is up to date")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (showFailureMessage) {
|
||||
_snackbarMessages.emit(e.message ?: "Update check failed")
|
||||
}
|
||||
} finally {
|
||||
_isCheckingForUpdates.value = false
|
||||
checkMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun installAvailableUpdate() {
|
||||
val update = _availableUpdate.value ?: return
|
||||
if (!installMutex.tryLock()) {
|
||||
return
|
||||
}
|
||||
|
||||
_availableUpdate.value = null
|
||||
try {
|
||||
_snackbarMessages.emit(appUpdateInstaller.installUpdate(update))
|
||||
} catch (e: Exception) {
|
||||
_snackbarMessages.emit(e.message ?: "Update install failed")
|
||||
} finally {
|
||||
installMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
fun declineUpdate() {
|
||||
_availableUpdate.value = null
|
||||
}
|
||||
|
||||
private fun installUpdateSetting(update: AppUpdateInfo): VoidSetting {
|
||||
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
|
||||
return VoidSetting(
|
||||
key = INSTALL_APP_UPDATE_KEY,
|
||||
title = "Install Purefin $versionLabel",
|
||||
onClick = { installAvailableUpdate() }
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val INSTALL_APP_UPDATE_KEY = "install_app_update"
|
||||
}
|
||||
}
|
||||
@@ -3,76 +3,35 @@ package hu.bbara.purefin.core.feature.update
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
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.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AppUpdateViewModel @Inject constructor(
|
||||
private val appUpdateRepository: AppUpdateRepository,
|
||||
private val appUpdateInstaller: AppUpdateInstaller
|
||||
private val appUpdateController: AppUpdateController
|
||||
) : ViewModel() {
|
||||
|
||||
private val _isCheckingForUpdates = MutableStateFlow(false)
|
||||
val isCheckingForUpdates: StateFlow<Boolean> = _isCheckingForUpdates.asStateFlow()
|
||||
val isCheckingForUpdates: StateFlow<Boolean> = appUpdateController.isCheckingForUpdates
|
||||
|
||||
private val _availableUpdate = MutableStateFlow<AppUpdateInfo?>(null)
|
||||
val availableUpdate: StateFlow<AppUpdateInfo?> = _availableUpdate.asStateFlow()
|
||||
val availableUpdate: StateFlow<AppUpdateInfo?> = appUpdateController.availableUpdate
|
||||
|
||||
private val _snackbarMessages = MutableSharedFlow<String>()
|
||||
val snackbarMessages: SharedFlow<String> = _snackbarMessages.asSharedFlow()
|
||||
|
||||
private var isInstallingUpdate = false
|
||||
val snackbarMessages: SharedFlow<String> = appUpdateController.snackbarMessages
|
||||
|
||||
fun checkForUpdates(showUpToDateMessage: Boolean = true) {
|
||||
if (_isCheckingForUpdates.value) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_isCheckingForUpdates.value = true
|
||||
try {
|
||||
val update = appUpdateRepository.checkForUpdate()
|
||||
if (update == null) {
|
||||
if (showUpToDateMessage) {
|
||||
_snackbarMessages.emit("Purefin is up to date")
|
||||
}
|
||||
} else {
|
||||
_availableUpdate.value = update
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_snackbarMessages.emit(e.message ?: "Update check failed")
|
||||
} finally {
|
||||
_isCheckingForUpdates.value = false
|
||||
}
|
||||
appUpdateController.checkForUpdates(showUpToDateMessage = showUpToDateMessage)
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptUpdate() {
|
||||
val update = _availableUpdate.value ?: return
|
||||
if (isInstallingUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
isInstallingUpdate = true
|
||||
_availableUpdate.value = null
|
||||
try {
|
||||
_snackbarMessages.emit(appUpdateInstaller.installUpdate(update))
|
||||
} catch (e: Exception) {
|
||||
_snackbarMessages.emit(e.message ?: "Update install failed")
|
||||
} finally {
|
||||
isInstallingUpdate = false
|
||||
}
|
||||
appUpdateController.installAvailableUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
fun declineUpdate() {
|
||||
_availableUpdate.value = null
|
||||
appUpdateController.declineUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ data class StringSetting(
|
||||
data class VoidSetting(
|
||||
override val key: String,
|
||||
override val title: String,
|
||||
override val defaultValue: Unit = Unit
|
||||
override val defaultValue: Unit = Unit,
|
||||
val onClick: suspend () -> Unit = {}
|
||||
) : SettingOption<Unit>
|
||||
|
||||
data class DropdownSetting<T>(
|
||||
@@ -44,18 +45,6 @@ data class SettingGroup(
|
||||
)
|
||||
|
||||
object SettingsOptions {
|
||||
val defaultPlaybackSpeed = RangeSetting(
|
||||
key = "default_playback_speed",
|
||||
title = "Default playback speed",
|
||||
defaultValue = 1.0,
|
||||
valueRange = 0.5..2.0
|
||||
)
|
||||
|
||||
val confirmMobileDataPlayback = BooleanSetting(
|
||||
key = "confirm_mobile_data_playback",
|
||||
title = "Confirm mobile data playback",
|
||||
defaultValue = true
|
||||
)
|
||||
|
||||
val autoPlayNextMedia = BooleanSetting(
|
||||
key = "auto_play_next_media",
|
||||
@@ -63,34 +52,11 @@ object SettingsOptions {
|
||||
defaultValue = true
|
||||
)
|
||||
|
||||
val preferredAudioLanguage = StringSetting(
|
||||
key = "preferred_audio_language",
|
||||
title = "Preferred audio language",
|
||||
defaultValue = "English"
|
||||
)
|
||||
|
||||
val resetPlaybackSettings = VoidSetting(
|
||||
key = "reset_playback_settings",
|
||||
title = "Reset playback settings"
|
||||
)
|
||||
|
||||
val streamingQuality = DropdownSetting(
|
||||
key = "streaming_quality",
|
||||
title = "Streaming quality",
|
||||
defaultValue = "Auto",
|
||||
options = listOf("Auto", "Low", "Medium", "High")
|
||||
)
|
||||
|
||||
val groups = listOf(
|
||||
SettingGroup(
|
||||
title = "Playback",
|
||||
options = listOf(
|
||||
defaultPlaybackSpeed,
|
||||
confirmMobileDataPlayback,
|
||||
autoPlayNextMedia,
|
||||
preferredAudioLanguage,
|
||||
resetPlaybackSettings,
|
||||
streamingQuality
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package hu.bbara.purefin.core.settings
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface SettingsGroupProvider {
|
||||
val settingGroups: Flow<List<SettingGroup>>
|
||||
}
|
||||
@@ -4,8 +4,11 @@ import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import hu.bbara.purefin.core.feature.update.AppUpdateController
|
||||
import hu.bbara.purefin.core.feature.update.AppUpdateInstaller
|
||||
import hu.bbara.purefin.core.feature.update.AppVersionProvider
|
||||
import hu.bbara.purefin.core.settings.SettingsGroupProvider
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@@ -16,4 +19,8 @@ abstract class AppUpdateModule {
|
||||
|
||||
@Binds
|
||||
abstract fun bindAppVersionProvider(impl: AndroidAppVersionProvider): AppVersionProvider
|
||||
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bindAppUpdateSettingsProvider(impl: AppUpdateController): SettingsGroupProvider
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user