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:
2026-02-16 16:01:23 +01:00
parent c2a0eb60c8
commit 98042b97ed
14 changed files with 151 additions and 16 deletions

View File

@@ -3,7 +3,7 @@ package hu.bbara.purefin.app.content.episode
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.data.model.Episode
import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.navigation.NavigationManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -17,7 +17,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class EpisodeScreenViewModel @Inject constructor( class EpisodeScreenViewModel @Inject constructor(
private val mediaRepository: InMemoryMediaRepository, private val mediaRepository: MediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
): ViewModel() { ): ViewModel() {

View File

@@ -3,7 +3,7 @@ package hu.bbara.purefin.app.content.series
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.data.model.Series
import hu.bbara.purefin.navigation.EpisodeDto import hu.bbara.purefin.navigation.EpisodeDto
import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.navigation.NavigationManager
@@ -21,7 +21,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SeriesViewModel @Inject constructor( class SeriesViewModel @Inject constructor(
private val mediaRepository: InMemoryMediaRepository, private val mediaRepository: MediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {

View File

@@ -37,6 +37,7 @@ fun HomePage(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val libraries = viewModel.libraries.collectAsState().value val libraries = viewModel.libraries.collectAsState().value
val isOfflineMode = viewModel.isOfflineMode.collectAsState().value
val libraryNavItems = libraries.map { val libraryNavItems = libraries.map {
HomeNavItem( HomeNavItem(
id = it.id, id = it.id,
@@ -85,7 +86,9 @@ fun HomePage(
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
topBar = { topBar = {
HomeTopBar( HomeTopBar(
onMenuClick = { coroutineScope.launch { drawerState.open() } } onMenuClick = { coroutineScope.launch { drawerState.open() } },
isOfflineMode = isOfflineMode,
onToggleOfflineMode = viewModel::toggleOfflineMode
) )
} }
) { innerPadding -> ) { innerPadding ->

View File

@@ -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.NextUpItem
import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.client.JellyfinApiClient 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.data.model.Media
import hu.bbara.purefin.domain.usecase.RefreshHomeDataUseCase import hu.bbara.purefin.domain.usecase.RefreshHomeDataUseCase
import hu.bbara.purefin.image.JellyfinImageHelper import hu.bbara.purefin.image.JellyfinImageHelper
@@ -34,7 +34,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class HomePageViewModel @Inject constructor( class HomePageViewModel @Inject constructor(
private val mediaRepository: InMemoryMediaRepository, private val mediaRepository: MediaRepository,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
@@ -50,6 +50,12 @@ class HomePageViewModel @Inject constructor(
private val _libraries = MutableStateFlow<List<LibraryItem>>(emptyList()) private val _libraries = MutableStateFlow<List<LibraryItem>>(emptyList())
val libraries = _libraries.asStateFlow() val libraries = _libraries.asStateFlow()
val isOfflineMode = userSessionRepository.isOfflineMode.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = false
)
init { init {
viewModelScope.launch { viewModelScope.launch {
loadLibraries() loadLibraries()
@@ -213,4 +219,10 @@ class HomePageViewModel @Inject constructor(
} }
} }
fun toggleOfflineMode() {
viewModelScope.launch {
userSessionRepository.setOfflineMode(!isOfflineMode.value)
}
}
} }

View File

@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons 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.material.icons.outlined.Menu
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -20,6 +22,8 @@ import hu.bbara.purefin.common.ui.components.PurefinIconButton
@Composable @Composable
fun HomeTopBar( fun HomeTopBar(
onMenuClick: () -> Unit, onMenuClick: () -> Unit,
isOfflineMode: Boolean,
onToggleOfflineMode: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -45,6 +49,13 @@ fun HomeTopBar(
onClick = onMenuClick 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
)
}
} }
} }
} }

View File

@@ -5,7 +5,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.client.JellyfinApiClient 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.data.model.Media
import hu.bbara.purefin.image.JellyfinImageHelper import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.navigation.MovieDto import hu.bbara.purefin.navigation.MovieDto
@@ -26,7 +26,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LibraryViewModel @Inject constructor( class LibraryViewModel @Inject constructor(
private val mediaRepository: InMemoryMediaRepository, private val mediaRepository: MediaRepository,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
private val navigationManager: NavigationManager private val navigationManager: NavigationManager

View File

@@ -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()
}
}

View File

@@ -65,13 +65,13 @@ class InMemoryMediaRepository @Inject constructor(
} }
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList()) 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()) 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()) 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 { init {
scope.launch { scope.launch {

View File

@@ -1,6 +1,7 @@
package hu.bbara.purefin.data package hu.bbara.purefin.data
import hu.bbara.purefin.data.model.Episode 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.Movie
import hu.bbara.purefin.data.model.Series import hu.bbara.purefin.data.model.Series
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -14,6 +15,10 @@ interface MediaRepository {
val episodes: StateFlow<Map<UUID, Episode>> val episodes: StateFlow<Map<UUID, Episode>>
val state: StateFlow<MediaRepositoryState> 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?> fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
suspend fun ensureReady() suspend fun ensureReady()

View File

@@ -19,7 +19,7 @@ abstract class MediaRepositoryModule {
@OfflineRepository @OfflineRepository
abstract fun bindOfflineMediaRepository(impl: OfflineMediaRepository): MediaRepository 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 @Binds
abstract fun bindDefaultMediaRepository(impl: InMemoryMediaRepository): MediaRepository abstract fun bindDefaultMediaRepository(impl: ActiveMediaRepository): MediaRepository
} }

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.data
import hu.bbara.purefin.data.local.room.OfflineDatabase import hu.bbara.purefin.data.local.room.OfflineDatabase
import hu.bbara.purefin.data.local.room.OfflineRoomMediaLocalDataSource import hu.bbara.purefin.data.local.room.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.data.model.Episode 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.Movie
import hu.bbara.purefin.data.model.Series import hu.bbara.purefin.data.model.Series
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -42,6 +43,11 @@ class OfflineMediaRepository @Inject constructor(
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap()) .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?> { override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return localDataSource.observeSeriesWithContent(seriesId) return localDataSource.observeSeriesWithContent(seriesId)
} }

View File

@@ -10,5 +10,6 @@ data class UserSession(
val url: String, val url: String,
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
val userId: UUID?, val userId: UUID?,
val loggedIn: Boolean val loggedIn: Boolean,
val isOfflineMode: Boolean = false
) )

View File

@@ -49,4 +49,12 @@ class UserSessionRepository @Inject constructor(
it.copy(loggedIn = isLoggedIn) it.copy(loggedIn = isLoggedIn)
} }
} }
val isOfflineMode: Flow<Boolean> = session.map { it.isOfflineMode }.distinctUntilChanged()
suspend fun setOfflineMode(isOffline: Boolean) {
userSessionDataStore.updateData {
it.copy(isOfflineMode = isOffline)
}
}
} }

View File

@@ -9,7 +9,7 @@ import java.io.OutputStream
object UserSessionSerializer : Serializer<UserSession> { object UserSessionSerializer : Serializer<UserSession> {
override val defaultValue: 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 { override suspend fun readFrom(input: InputStream): UserSession {
try { try {