mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
feat: add online/offline mode toggle to HomeScreen
Implements a toggle switch in the HomeScreen top bar (next to Menu) that allows users to switch between online mode (fetching from Jellyfin server) and offline mode (using local database only). The preference persists across app restarts via Proto DataStore. Key changes: - Added ActiveMediaRepository that delegates to online/offline repositories based on user preference - Extended MediaRepository interface with continueWatching, nextUp, and latestLibraryContent - Added isOfflineMode state to UserSession with reactive Flow - Added Cloud/CloudOff icon toggle button to HomeTopBar - Updated ViewModels to use MediaRepository interface for better abstraction
This commit is contained in:
@@ -3,7 +3,7 @@ package hu.bbara.purefin.app.content.episode
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.data.InMemoryMediaRepository
|
||||
import hu.bbara.purefin.data.MediaRepository
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -17,7 +17,7 @@ import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class EpisodeScreenViewModel @Inject constructor(
|
||||
private val mediaRepository: InMemoryMediaRepository,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
): ViewModel() {
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package hu.bbara.purefin.app.content.series
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.data.InMemoryMediaRepository
|
||||
import hu.bbara.purefin.data.MediaRepository
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import hu.bbara.purefin.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
@@ -21,7 +21,7 @@ import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SeriesViewModel @Inject constructor(
|
||||
private val mediaRepository: InMemoryMediaRepository,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
) : ViewModel() {
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ fun HomePage(
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val libraries = viewModel.libraries.collectAsState().value
|
||||
val isOfflineMode = viewModel.isOfflineMode.collectAsState().value
|
||||
val libraryNavItems = libraries.map {
|
||||
HomeNavItem(
|
||||
id = it.id,
|
||||
@@ -85,7 +86,9 @@ fun HomePage(
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
HomeTopBar(
|
||||
onMenuClick = { coroutineScope.launch { drawerState.open() } }
|
||||
onMenuClick = { coroutineScope.launch { drawerState.open() } },
|
||||
isOfflineMode = isOfflineMode,
|
||||
onToggleOfflineMode = viewModel::toggleOfflineMode
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
|
||||
@@ -9,7 +9,7 @@ import hu.bbara.purefin.app.home.ui.LibraryItem
|
||||
import hu.bbara.purefin.app.home.ui.NextUpItem
|
||||
import hu.bbara.purefin.app.home.ui.PosterItem
|
||||
import hu.bbara.purefin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.data.InMemoryMediaRepository
|
||||
import hu.bbara.purefin.data.MediaRepository
|
||||
import hu.bbara.purefin.data.model.Media
|
||||
import hu.bbara.purefin.domain.usecase.RefreshHomeDataUseCase
|
||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||
@@ -34,7 +34,7 @@ import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HomePageViewModel @Inject constructor(
|
||||
private val mediaRepository: InMemoryMediaRepository,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
@@ -50,6 +50,12 @@ class HomePageViewModel @Inject constructor(
|
||||
private val _libraries = MutableStateFlow<List<LibraryItem>>(emptyList())
|
||||
val libraries = _libraries.asStateFlow()
|
||||
|
||||
val isOfflineMode = userSessionRepository.isOfflineMode.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = false
|
||||
)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
loadLibraries()
|
||||
@@ -213,4 +219,10 @@ class HomePageViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleOfflineMode() {
|
||||
viewModelScope.launch {
|
||||
userSessionRepository.setOfflineMode(!isOfflineMode.value)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Cloud
|
||||
import androidx.compose.material.icons.outlined.CloudOff
|
||||
import androidx.compose.material.icons.outlined.Menu
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -20,6 +22,8 @@ import hu.bbara.purefin.common.ui.components.PurefinIconButton
|
||||
@Composable
|
||||
fun HomeTopBar(
|
||||
onMenuClick: () -> Unit,
|
||||
isOfflineMode: Boolean,
|
||||
onToggleOfflineMode: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -45,6 +49,13 @@ fun HomeTopBar(
|
||||
onClick = onMenuClick
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
PurefinIconButton(
|
||||
icon = if (isOfflineMode) Icons.Outlined.CloudOff else Icons.Outlined.Cloud,
|
||||
contentDescription = if (isOfflineMode) "Switch to Online" else "Switch to Offline",
|
||||
onClick = onToggleOfflineMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.app.home.ui.PosterItem
|
||||
import hu.bbara.purefin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.data.InMemoryMediaRepository
|
||||
import hu.bbara.purefin.data.MediaRepository
|
||||
import hu.bbara.purefin.data.model.Media
|
||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.navigation.MovieDto
|
||||
@@ -26,7 +26,7 @@ import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class LibraryViewModel @Inject constructor(
|
||||
private val mediaRepository: InMemoryMediaRepository,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
private val navigationManager: NavigationManager
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package hu.bbara.purefin.data
|
||||
|
||||
import hu.bbara.purefin.data.local.room.OfflineRepository
|
||||
import hu.bbara.purefin.data.local.room.OnlineRepository
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Media
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Active media repository that delegates to either online or offline repository
|
||||
* based on user preference.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Singleton
|
||||
class ActiveMediaRepository @Inject constructor(
|
||||
@OnlineRepository private val onlineRepository: MediaRepository,
|
||||
@OfflineRepository private val offlineRepository: MediaRepository,
|
||||
private val userSessionRepository: UserSessionRepository
|
||||
) : MediaRepository {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// Switch between repositories based on offline mode preference
|
||||
private val activeRepository: StateFlow<MediaRepository> =
|
||||
userSessionRepository.isOfflineMode
|
||||
.map { isOffline ->
|
||||
if (isOffline) offlineRepository else onlineRepository
|
||||
}
|
||||
.stateIn(scope, SharingStarted.Eagerly, onlineRepository)
|
||||
|
||||
// Delegate all MediaRepository interface methods to the active repository
|
||||
override val movies: StateFlow<Map<UUID, Movie>> =
|
||||
activeRepository.flatMapLatest { it.movies }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val series: StateFlow<Map<UUID, Series>> =
|
||||
activeRepository.flatMapLatest { it.series }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val episodes: StateFlow<Map<UUID, Episode>> =
|
||||
activeRepository.flatMapLatest { it.episodes }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override val state: StateFlow<MediaRepositoryState> =
|
||||
activeRepository.flatMapLatest { it.state }
|
||||
.stateIn(scope, SharingStarted.Eagerly, MediaRepositoryState.Loading)
|
||||
|
||||
override val continueWatching: StateFlow<List<Media>> =
|
||||
activeRepository.flatMapLatest { it.continueWatching }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
override val nextUp: StateFlow<List<Media>> =
|
||||
activeRepository.flatMapLatest { it.nextUp }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> =
|
||||
activeRepository.flatMapLatest { it.latestLibraryContent }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
|
||||
activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) }
|
||||
|
||||
override suspend fun ensureReady() {
|
||||
activeRepository.value.ensureReady()
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||
activeRepository.value.updateWatchProgress(mediaId, positionMs, durationMs)
|
||||
}
|
||||
|
||||
override suspend fun refreshHomeData() {
|
||||
activeRepository.value.refreshHomeData()
|
||||
}
|
||||
}
|
||||
@@ -65,13 +65,13 @@ class InMemoryMediaRepository @Inject constructor(
|
||||
}
|
||||
|
||||
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
|
||||
val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
|
||||
override val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
|
||||
|
||||
private val _nextUp: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
|
||||
val nextUp: StateFlow<List<Media>> = _nextUp.asStateFlow()
|
||||
override val nextUp: StateFlow<List<Media>> = _nextUp.asStateFlow()
|
||||
|
||||
private val _latestLibraryContent: MutableStateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
|
||||
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = _latestLibraryContent.asStateFlow()
|
||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = _latestLibraryContent.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package hu.bbara.purefin.data
|
||||
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Media
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -14,6 +15,10 @@ interface MediaRepository {
|
||||
val episodes: StateFlow<Map<UUID, Episode>>
|
||||
val state: StateFlow<MediaRepositoryState>
|
||||
|
||||
val continueWatching: StateFlow<List<Media>>
|
||||
val nextUp: StateFlow<List<Media>>
|
||||
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
|
||||
|
||||
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
|
||||
|
||||
suspend fun ensureReady()
|
||||
|
||||
@@ -19,7 +19,7 @@ abstract class MediaRepositoryModule {
|
||||
@OfflineRepository
|
||||
abstract fun bindOfflineMediaRepository(impl: OfflineMediaRepository): MediaRepository
|
||||
|
||||
// Default binding for backward compatibility (uses online repository)
|
||||
// Default binding delegates to online/offline based on user preference
|
||||
@Binds
|
||||
abstract fun bindDefaultMediaRepository(impl: InMemoryMediaRepository): MediaRepository
|
||||
abstract fun bindDefaultMediaRepository(impl: ActiveMediaRepository): MediaRepository
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package hu.bbara.purefin.data
|
||||
import hu.bbara.purefin.data.local.room.OfflineDatabase
|
||||
import hu.bbara.purefin.data.local.room.OfflineRoomMediaLocalDataSource
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Media
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -42,6 +43,11 @@ class OfflineMediaRepository @Inject constructor(
|
||||
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
// Offline mode doesn't support these server-side features
|
||||
override val continueWatching: StateFlow<List<Media>> = MutableStateFlow(emptyList())
|
||||
override val nextUp: StateFlow<List<Media>> = MutableStateFlow(emptyList())
|
||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
|
||||
|
||||
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
|
||||
return localDataSource.observeSeriesWithContent(seriesId)
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ data class UserSession(
|
||||
val url: String,
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val userId: UUID?,
|
||||
val loggedIn: Boolean
|
||||
val loggedIn: Boolean,
|
||||
val isOfflineMode: Boolean = false
|
||||
)
|
||||
|
||||
@@ -49,4 +49,12 @@ class UserSessionRepository @Inject constructor(
|
||||
it.copy(loggedIn = isLoggedIn)
|
||||
}
|
||||
}
|
||||
|
||||
val isOfflineMode: Flow<Boolean> = session.map { it.isOfflineMode }.distinctUntilChanged()
|
||||
|
||||
suspend fun setOfflineMode(isOffline: Boolean) {
|
||||
userSessionDataStore.updateData {
|
||||
it.copy(isOfflineMode = isOffline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import java.io.OutputStream
|
||||
|
||||
object UserSessionSerializer : Serializer<UserSession> {
|
||||
override val defaultValue: UserSession
|
||||
get() = UserSession(accessToken = "", url = "", loggedIn = false, userId = null)
|
||||
get() = UserSession(accessToken = "", url = "", loggedIn = false, userId = null, isOfflineMode = false)
|
||||
|
||||
override suspend fun readFrom(input: InputStream): UserSession {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user