From 98042b97edc74d84aa8ebe004896857cde19a294 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Mon, 16 Feb 2026 16:01:23 +0100 Subject: [PATCH] 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 --- .../content/episode/EpisodeScreenViewModel.kt | 4 +- .../app/content/series/SeriesViewModel.kt | 4 +- .../hu/bbara/purefin/app/home/HomePage.kt | 5 +- .../purefin/app/home/HomePageViewModel.kt | 16 +++- .../bbara/purefin/app/home/ui/HomeTopBar.kt | 11 +++ .../purefin/app/library/LibraryViewModel.kt | 4 +- .../purefin/data/ActiveMediaRepository.kt | 89 +++++++++++++++++++ .../purefin/data/InMemoryMediaRepository.kt | 6 +- .../hu/bbara/purefin/data/MediaRepository.kt | 5 ++ .../purefin/data/MediaRepositoryModule.kt | 4 +- .../purefin/data/OfflineMediaRepository.kt | 6 ++ .../hu/bbara/purefin/session/UserSession.kt | 3 +- .../purefin/session/UserSessionRepository.kt | 8 ++ .../purefin/session/UserSessionSerializer.kt | 2 +- 14 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/hu/bbara/purefin/data/ActiveMediaRepository.kt diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt index 365548c..f5295fe 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt @@ -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() { diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt index ce34d1b..f5c8295 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt @@ -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() { diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt index 1118939..b54cd8f 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt @@ -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 -> diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt index 2e9eba1..a387009 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt @@ -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>(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) + } + } + } diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt index 2ec791f..dfce61d 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt @@ -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 + ) + } } } } diff --git a/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt index b943f2b..7533593 100644 --- a/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt @@ -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 diff --git a/app/src/main/java/hu/bbara/purefin/data/ActiveMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/ActiveMediaRepository.kt new file mode 100644 index 0000000..a30d28e --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/ActiveMediaRepository.kt @@ -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 = + 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> = + activeRepository.flatMapLatest { it.movies } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + override val series: StateFlow> = + activeRepository.flatMapLatest { it.series } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + override val episodes: StateFlow> = + activeRepository.flatMapLatest { it.episodes } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + override val state: StateFlow = + activeRepository.flatMapLatest { it.state } + .stateIn(scope, SharingStarted.Eagerly, MediaRepositoryState.Loading) + + override val continueWatching: StateFlow> = + activeRepository.flatMapLatest { it.continueWatching } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + override val nextUp: StateFlow> = + activeRepository.flatMapLatest { it.nextUp } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + override val latestLibraryContent: StateFlow>> = + activeRepository.flatMapLatest { it.latestLibraryContent } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + override fun observeSeriesWithContent(seriesId: UUID): Flow = + 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() + } +} diff --git a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt index ec934c0..ae163cd 100644 --- a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt @@ -65,13 +65,13 @@ class InMemoryMediaRepository @Inject constructor( } private val _continueWatching: MutableStateFlow> = MutableStateFlow(emptyList()) - val continueWatching: StateFlow> = _continueWatching.asStateFlow() + override val continueWatching: StateFlow> = _continueWatching.asStateFlow() private val _nextUp: MutableStateFlow> = MutableStateFlow(emptyList()) - val nextUp: StateFlow> = _nextUp.asStateFlow() + override val nextUp: StateFlow> = _nextUp.asStateFlow() private val _latestLibraryContent: MutableStateFlow>> = MutableStateFlow(emptyMap()) - val latestLibraryContent: StateFlow>> = _latestLibraryContent.asStateFlow() + override val latestLibraryContent: StateFlow>> = _latestLibraryContent.asStateFlow() init { scope.launch { diff --git a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt index d78f542..82b8b84 100644 --- a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt @@ -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> val state: StateFlow + val continueWatching: StateFlow> + val nextUp: StateFlow> + val latestLibraryContent: StateFlow>> + fun observeSeriesWithContent(seriesId: UUID): Flow suspend fun ensureReady() diff --git a/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt b/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt index d526b99..dc1c5f9 100644 --- a/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt +++ b/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryModule.kt @@ -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 } diff --git a/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt index 075a2b7..b677004 100644 --- a/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/OfflineMediaRepository.kt @@ -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> = localDataSource.episodesFlow .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + // Offline mode doesn't support these server-side features + override val continueWatching: StateFlow> = MutableStateFlow(emptyList()) + override val nextUp: StateFlow> = MutableStateFlow(emptyList()) + override val latestLibraryContent: StateFlow>> = MutableStateFlow(emptyMap()) + override fun observeSeriesWithContent(seriesId: UUID): Flow { return localDataSource.observeSeriesWithContent(seriesId) } diff --git a/app/src/main/java/hu/bbara/purefin/session/UserSession.kt b/app/src/main/java/hu/bbara/purefin/session/UserSession.kt index ffaf8a1..3622a00 100644 --- a/app/src/main/java/hu/bbara/purefin/session/UserSession.kt +++ b/app/src/main/java/hu/bbara/purefin/session/UserSession.kt @@ -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 ) diff --git a/app/src/main/java/hu/bbara/purefin/session/UserSessionRepository.kt b/app/src/main/java/hu/bbara/purefin/session/UserSessionRepository.kt index 6f59128..de57eff 100644 --- a/app/src/main/java/hu/bbara/purefin/session/UserSessionRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/session/UserSessionRepository.kt @@ -49,4 +49,12 @@ class UserSessionRepository @Inject constructor( it.copy(loggedIn = isLoggedIn) } } + + val isOfflineMode: Flow = session.map { it.isOfflineMode }.distinctUntilChanged() + + suspend fun setOfflineMode(isOffline: Boolean) { + userSessionDataStore.updateData { + it.copy(isOfflineMode = isOffline) + } + } } diff --git a/app/src/main/java/hu/bbara/purefin/session/UserSessionSerializer.kt b/app/src/main/java/hu/bbara/purefin/session/UserSessionSerializer.kt index 8b779a3..35ac4fa 100644 --- a/app/src/main/java/hu/bbara/purefin/session/UserSessionSerializer.kt +++ b/app/src/main/java/hu/bbara/purefin/session/UserSessionSerializer.kt @@ -9,7 +9,7 @@ import java.io.OutputStream object UserSessionSerializer : Serializer { 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 {