From 333063bd1ea36efcf75734c946cfef6d071ab111 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:14:29 +0530 Subject: [PATCH] feat: added metascreen reordering --- composeApp/build.gradle.kts | 1 + .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../MetaScreenSettingsStorage.android.kt | 26 ++ .../commonMain/kotlin/com/nuvio/app/App.kt | 12 + .../core/storage/LocalAccountDataCleaner.kt | 2 + .../app/features/details/MetaDetailsScreen.kt | 420 +++++++++++------- .../details/MetaScreenSettingsRepository.kt | 236 ++++++++++ .../details/MetaScreenSettingsStorage.kt | 6 + .../home/HomeCatalogSettingsRepository.kt | 29 ++ .../features/profiles/ProfileRepository.kt | 2 + .../settings/ContentDiscoverySettingsPage.kt | 8 + .../settings/HomescreenSettingsPage.kt | 71 ++- .../settings/MetaScreenSettingsPage.kt | 179 ++++++++ .../features/settings/SettingsComponents.kt | 115 +++-- .../settings/SettingsFullScreenPages.kt | 28 ++ .../app/features/settings/SettingsModels.kt | 5 + .../app/features/settings/SettingsScreen.kt | 23 + .../details/MetaScreenSettingsStorage.ios.kt | 15 + gradle/libs.versions.toml | 2 + 19 files changed, 937 insertions(+), 245 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 965af61b8..83353f11f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -158,6 +158,7 @@ kotlin { implementation(libs.supabase.functions) implementation(libs.quickjs.kt) implementation(libs.ksoup) + implementation(libs.reorderable) } iosMain.dependencies { implementation(libs.ktor.client.darwin) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 1b98d0aef..aac16a46f 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -10,6 +10,7 @@ import androidx.core.view.WindowCompat import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner import com.nuvio.app.features.addons.AddonStorage import com.nuvio.app.features.library.LibraryStorage +import com.nuvio.app.features.details.MetaScreenSettingsStorage import com.nuvio.app.features.home.HomeCatalogSettingsStorage import com.nuvio.app.features.mdblist.MdbListSettingsStorage import com.nuvio.app.features.player.PlayerSettingsStorage @@ -42,6 +43,7 @@ class MainActivity : ComponentActivity() { AddonStorage.initialize(applicationContext) LibraryStorage.initialize(applicationContext) WatchedStorage.initialize(applicationContext) + MetaScreenSettingsStorage.initialize(applicationContext) HomeCatalogSettingsStorage.initialize(applicationContext) PlayerSettingsStorage.initialize(applicationContext) ProfileStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.android.kt new file mode 100644 index 000000000..5fd0d2222 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.android.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.features.details + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object MetaScreenSettingsStorage { + private const val preferencesName = "nuvio_meta_screen_settings" + private const val payloadKey = "meta_screen_settings_payload" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadPayload(): String? = + preferences?.getString(ProfileScopedKey.of(payloadKey), null) + + actual fun savePayload(payload: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(payloadKey), payload) + ?.apply() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index cc1e85b83..dd927c52a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -100,6 +100,7 @@ import com.nuvio.app.features.profiles.ProfileSwitcherTab import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.settings.SettingsScreen import com.nuvio.app.features.settings.HomescreenSettingsScreen +import com.nuvio.app.features.settings.MetaScreenSettingsScreen import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen import com.nuvio.app.features.settings.AddonsSettingsScreen import com.nuvio.app.features.settings.PluginsSettingsScreen @@ -132,6 +133,9 @@ data class DetailRoute(val type: String, val id: String) @Serializable object HomescreenSettingsRoute +@Serializable +object MetaScreenSettingsRoute + @Serializable object ContinueWatchingSettingsRoute @@ -537,6 +541,7 @@ private fun MainAppContent( onContinueWatchingLongPress = onContinueWatchingLongPress, onSwitchProfile = onSwitchProfile, onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, + onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) }, onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, onPluginsSettingsClick = { navController.navigate(PluginsSettingsRoute) }, @@ -855,6 +860,11 @@ private fun MainAppContent( onBack = { navController.popBackStack() }, ) } + composable { + MetaScreenSettingsScreen( + onBack = { navController.popBackStack() }, + ) + } composable { ContinueWatchingSettingsScreen( onBack = { navController.popBackStack() }, @@ -935,6 +945,7 @@ private fun AppTabHost( onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null, onSwitchProfile: (() -> Unit)? = null, onHomescreenSettingsClick: () -> Unit = {}, + onMetaScreenSettingsClick: () -> Unit = {}, onContinueWatchingSettingsClick: () -> Unit = {}, onAddonsSettingsClick: () -> Unit = {}, onPluginsSettingsClick: () -> Unit = {}, @@ -980,6 +991,7 @@ private fun AppTabHost( modifier = Modifier.fillMaxSize(), onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenSettingsClick, + onMetaScreenClick = onMetaScreenSettingsClick, onContinueWatchingClick = onContinueWatchingSettingsClick, onAddonsClick = onAddonsSettingsClick, onPluginsClick = onPluginsSettingsClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index 8e97767b1..b04c14497 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -3,6 +3,7 @@ package com.nuvio.app.core.storage import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeRepository import com.nuvio.app.features.library.LibraryRepository @@ -29,6 +30,7 @@ internal object LocalAccountDataCleaner { PluginRepository.clearLocalState() HomeRepository.clear() HomeCatalogSettingsRepository.clearLocalState() + MetaScreenSettingsRepository.clearLocalState() LibraryRepository.clearLocalState() WatchProgressRepository.clearLocalState() WatchedRepository.clearLocalState() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 0db9f7c89..14bebf90a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -82,6 +82,7 @@ import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watched.previousReleasedEpisodesBefore import com.nuvio.app.features.watched.releasedEpisodesForSeason import com.nuvio.app.features.watchprogress.CurrentDateProvider +import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -100,6 +101,10 @@ fun MetaDetailsScreen( ) { val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle() val displayedMeta = MetaDetailsRepository.peek(type, id) + val metaScreenSettingsUiState by remember { + MetaScreenSettingsRepository.ensureLoaded() + MetaScreenSettingsRepository.uiState + }.collectAsStateWithLifecycle() val traktAuthUiState by remember { TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState @@ -343,6 +348,76 @@ fun MetaDetailsScreen( else -> "Play" } } + val onPrimaryPlayClick: () -> Unit = { + when { + (meta.type == "series" || hasEpisodes) && seriesAction != null -> { + onPlay?.invoke( + meta.type, + seriesStreamVideoId ?: seriesAction.videoId, + meta.id, + meta.type, + meta.name, + meta.logo, + meta.poster, + meta.background, + seriesAction.seasonNumber, + seriesAction.episodeNumber, + seriesAction.episodeTitle, + seriesAction.episodeThumbnail, + seriesPauseDescription, + seriesAction.resumePositionMs, + ) + } + + else -> { + onPlay?.invoke( + meta.type, + meta.id, + meta.id, + meta.type, + meta.name, + meta.logo, + meta.poster, + meta.background, + null, + null, + null, + null, + meta.description, + movieProgress?.lastPositionMs, + ) + } + } + } + val onEpisodePlayClick: (MetaVideo) -> Unit = { video -> + val season = video.season + val episode = video.episode + val playbackVideoId = buildPlaybackVideoId( + parentMetaId = meta.id, + seasonNumber = season, + episodeNumber = episode, + fallbackVideoId = video.id, + ) + val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId + val savedProgress = watchProgressUiState.byVideoId[playbackVideoId] + ?.takeUnless { it.isCompleted } + onPlay?.invoke( + meta.type, + streamVideoId, + meta.id, + meta.type, + meta.name, + meta.logo, + meta.poster, + meta.background, + season, + episode, + video.title, + video.thumbnail, + video.overview, + savedProgress?.lastPositionMs, + ) + } val scrollState = rememberScrollState() val density = LocalDensity.current val safeAreaTopPx = with(density) { @@ -390,179 +465,66 @@ fun MetaDetailsScreen( verticalArrangement = Arrangement.spacedBy(20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - DetailActionButtons( - playLabel = playButtonLabel, - saveLabel = if (isSaved) "Saved" else "Save", - isSaved = isSaved, + ConfiguredMetaSections( + settings = metaScreenSettingsUiState, + meta = meta, isTablet = isTablet, - onPlayClick = { - when { - (meta.type == "series" || hasEpisodes) && seriesAction != null -> { - onPlay?.invoke( - meta.type, - seriesStreamVideoId ?: seriesAction.videoId, - meta.id, - meta.type, - meta.name, - meta.logo, - meta.poster, - meta.background, - seriesAction.seasonNumber, - seriesAction.episodeNumber, - seriesAction.episodeTitle, - seriesAction.episodeThumbnail, - seriesPauseDescription, - seriesAction.resumePositionMs, - ) - } - - else -> { - onPlay?.invoke( - meta.type, - meta.id, - meta.id, - meta.type, - meta.name, - meta.logo, - meta.poster, - meta.background, - null, - null, - null, - null, - meta.description, - movieProgress?.lastPositionMs, - ) + playButtonLabel = playButtonLabel, + isSaved = isSaved, + onPrimaryPlayClick = onPrimaryPlayClick, + onSaveClick = toggleSaved, + hasProductionSection = hasProductionSection, + hasTrailersSection = hasTrailersSection, + hasEpisodes = hasEpisodes, + hasAdditionalInfoSection = hasAdditionalInfoSection, + hasCollectionSection = hasCollectionSection, + hasMoreLikeThisSection = hasMoreLikeThisSection, + shouldShowComments = shouldShowComments, + comments = comments, + isCommentsLoading = isCommentsLoading, + isCommentsLoadingMore = isCommentsLoadingMore, + commentsCurrentPage = commentsCurrentPage, + commentsPageCount = commentsPageCount, + commentsError = commentsError, + onRetryComments = { + detailsScope.launch { + isCommentsLoading = true + commentsError = null + try { + val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true) + comments = result.items + commentsCurrentPage = result.currentPage + commentsPageCount = result.pageCount + } catch (e: Exception) { + commentsError = e.message ?: "Failed to load comments" } + isCommentsLoading = false } }, - onSaveClick = toggleSaved, - ) - - DetailMetaInfo(meta = meta) - - if (hasEpisodes && hasProductionSection) { - DetailProductionSection(meta = meta) - } - - DetailCastSection(cast = meta.cast) - - if (shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())) { - DetailCommentsSection( - comments = comments, - isLoading = isCommentsLoading, - isLoadingMore = isCommentsLoadingMore, - canLoadMore = commentsCurrentPage < commentsPageCount, - error = commentsError, - onRetry = { - detailsScope.launch { - isCommentsLoading = true - commentsError = null - try { - val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true) - comments = result.items - commentsCurrentPage = result.currentPage - commentsPageCount = result.pageCount - } catch (e: Exception) { - commentsError = e.message ?: "Failed to load comments" - } - isCommentsLoading = false - } - }, - onLoadMore = { - detailsScope.launch { - isCommentsLoadingMore = true - try { - val nextPage = commentsCurrentPage + 1 - val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage) - val existingIds = comments.map { it.id }.toSet() - val newComments = result.items.filter { it.id !in existingIds } - comments = comments + newComments - commentsCurrentPage = result.currentPage - commentsPageCount = result.pageCount - } catch (_: Exception) { } - isCommentsLoadingMore = false - } - }, - onCommentClick = { review -> selectedComment = review }, - ) - } - - if (hasTrailersSection) { - DetailTrailersSection( - trailers = meta.trailers, - onTrailerClick = resolveTrailer, - ) - } - - if (!hasEpisodes && hasProductionSection) { - DetailProductionSection(meta = meta) - } - - DetailSeriesContent( - meta = meta, + onLoadMoreComments = { + detailsScope.launch { + isCommentsLoadingMore = true + try { + val nextPage = commentsCurrentPage + 1 + val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage) + val existingIds = comments.map { it.id }.toSet() + val newComments = result.items.filter { it.id !in existingIds } + comments = comments + newComments + commentsCurrentPage = result.currentPage + commentsPageCount = result.pageCount + } catch (_: Exception) { } + isCommentsLoadingMore = false + } + }, + onCommentClick = { review -> selectedComment = review }, + onTrailerClick = resolveTrailer, progressByVideoId = watchProgressUiState.byVideoId, watchedKeys = watchedUiState.watchedKeys, - onEpisodeClick = { video -> - val season = video.season - val episode = video.episode - val playbackVideoId = buildPlaybackVideoId( - parentMetaId = meta.id, - seasonNumber = season, - episodeNumber = episode, - fallbackVideoId = video.id, - ) - val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId - val savedProgress = watchProgressUiState.byVideoId[playbackVideoId] - ?.takeUnless { it.isCompleted } - onPlay?.invoke( - meta.type, - streamVideoId, - meta.id, - meta.type, - meta.name, - meta.logo, - meta.poster, - meta.background, - season, - episode, - video.title, - video.thumbnail, - video.overview, - savedProgress?.lastPositionMs, - ) - }, - onEpisodeLongPress = { video -> - selectedEpisodeForActions = video - }, + onEpisodeClick = onEpisodePlayClick, + onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, + onOpenMeta = onOpenMeta, ) - if (hasEpisodes && hasAdditionalInfoSection) { - DetailAdditionalInfoSection(meta = meta) - } - - if (!hasEpisodes && hasAdditionalInfoSection) { - DetailAdditionalInfoSection(meta = meta) - } - - if (!hasEpisodes && hasCollectionSection) { - DetailPosterRailSection( - title = meta.collectionName.orEmpty(), - items = meta.collectionItems, - watchedKeys = watchedUiState.watchedKeys, - onPosterClick = onOpenMeta, - ) - } - - if (hasMoreLikeThisSection) { - DetailPosterRailSection( - title = "More Like This", - items = meta.moreLikeThis, - watchedKeys = watchedUiState.watchedKeys, - onPosterClick = onOpenMeta, - ) - } - Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding)) } } @@ -768,6 +730,134 @@ fun MetaDetailsScreen( } } +@Composable +private fun ConfiguredMetaSections( + settings: MetaScreenSettingsUiState, + meta: MetaDetails, + isTablet: Boolean, + playButtonLabel: String, + isSaved: Boolean, + onPrimaryPlayClick: () -> Unit, + onSaveClick: () -> Unit, + hasProductionSection: Boolean, + hasTrailersSection: Boolean, + hasEpisodes: Boolean, + hasAdditionalInfoSection: Boolean, + hasCollectionSection: Boolean, + hasMoreLikeThisSection: Boolean, + shouldShowComments: Boolean, + comments: List, + isCommentsLoading: Boolean, + isCommentsLoadingMore: Boolean, + commentsCurrentPage: Int, + commentsPageCount: Int, + commentsError: String?, + onRetryComments: () -> Unit, + onLoadMoreComments: () -> Unit, + onCommentClick: (TraktCommentReview) -> Unit, + onTrailerClick: (MetaTrailer) -> Unit, + progressByVideoId: Map, + watchedKeys: Set, + onEpisodeClick: (MetaVideo) -> Unit, + onEpisodeLongPress: (MetaVideo) -> Unit, + onOpenMeta: ((MetaPreview) -> Unit)?, +) { + settings.items + .filter { it.enabled } + .forEach { section -> + when (section.key) { + MetaScreenSectionKey.ACTIONS -> { + DetailActionButtons( + playLabel = playButtonLabel, + saveLabel = if (isSaved) "Saved" else "Save", + isSaved = isSaved, + isTablet = isTablet, + onPlayClick = onPrimaryPlayClick, + onSaveClick = onSaveClick, + ) + } + + MetaScreenSectionKey.OVERVIEW -> { + DetailMetaInfo(meta = meta) + } + + MetaScreenSectionKey.PRODUCTION -> { + if (hasProductionSection) { + DetailProductionSection(meta = meta) + } + } + + MetaScreenSectionKey.CAST -> { + DetailCastSection(cast = meta.cast) + } + + MetaScreenSectionKey.COMMENTS -> { + if (shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())) { + DetailCommentsSection( + comments = comments, + isLoading = isCommentsLoading, + isLoadingMore = isCommentsLoadingMore, + canLoadMore = commentsCurrentPage < commentsPageCount, + error = commentsError, + onRetry = onRetryComments, + onLoadMore = onLoadMoreComments, + onCommentClick = onCommentClick, + ) + } + } + + MetaScreenSectionKey.TRAILERS -> { + if (hasTrailersSection) { + DetailTrailersSection( + trailers = meta.trailers, + onTrailerClick = onTrailerClick, + ) + } + } + + MetaScreenSectionKey.EPISODES -> { + if (hasEpisodes) { + DetailSeriesContent( + meta = meta, + progressByVideoId = progressByVideoId, + watchedKeys = watchedKeys, + onEpisodeClick = onEpisodeClick, + onEpisodeLongPress = onEpisodeLongPress, + ) + } + } + + MetaScreenSectionKey.DETAILS -> { + if (hasAdditionalInfoSection) { + DetailAdditionalInfoSection(meta = meta) + } + } + + MetaScreenSectionKey.COLLECTION -> { + if (!hasEpisodes && hasCollectionSection) { + DetailPosterRailSection( + title = meta.collectionName.orEmpty(), + items = meta.collectionItems, + watchedKeys = watchedKeys, + onPosterClick = onOpenMeta, + ) + } + } + + MetaScreenSectionKey.MORE_LIKE_THIS -> { + if (hasMoreLikeThisSection) { + DetailPosterRailSection( + title = "More Like This", + items = meta.moreLikeThis, + watchedKeys = watchedKeys, + onPosterClick = onOpenMeta, + ) + } + } + } + } +} + private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp = if (!isTablet) { maxWidth diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt new file mode 100644 index 000000000..6f3c9858a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt @@ -0,0 +1,236 @@ +package com.nuvio.app.features.details + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +enum class MetaScreenSectionKey { + ACTIONS, + OVERVIEW, + PRODUCTION, + CAST, + COMMENTS, + TRAILERS, + EPISODES, + DETAILS, + COLLECTION, + MORE_LIKE_THIS, +} + +data class MetaScreenSectionItem( + val key: MetaScreenSectionKey, + val title: String, + val description: String, + val enabled: Boolean, + val order: Int, +) + +data class MetaScreenSettingsUiState( + val items: List = emptyList(), +) + +@Serializable +private data class StoredMetaScreenSectionPreference( + val key: String, + val enabled: Boolean = true, + val order: Int = 0, +) + +@Serializable +private data class StoredMetaScreenSettingsPayload( + val items: List = emptyList(), +) + +private data class MetaScreenSectionDefinition( + val key: MetaScreenSectionKey, + val title: String, + val description: String, +) + +object MetaScreenSettingsRepository { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val definitions = listOf( + MetaScreenSectionDefinition( + key = MetaScreenSectionKey.ACTIONS, + title = "Actions", + description = "Play and save controls.", + ), + MetaScreenSectionDefinition( + key = MetaScreenSectionKey.OVERVIEW, + title = "Overview", + description = "Synopsis, ratings, genres, and core credits.", + ), + MetaScreenSectionDefinition( + key = MetaScreenSectionKey.PRODUCTION, + title = "Production", + description = "Studios and networks.", + ), + MetaScreenSectionDefinition( + key = MetaScreenSectionKey.CAST, + title = "Cast", + description = "Principal cast list.", + ), + MetaScreenSectionDefinition( + key = MetaScreenSectionKey.COMMENTS, + title = "Comments", + description = "Trakt comments section.", + ), + MetaScreenSectionDefinition( + key = MetaScreenSectionKey.TRAILERS, + title = "Trailers", + description = "Trailer rail and playback shortcuts.", + ), + MetaScreenSectionDefinition( + key = MetaScreenSectionKey.EPISODES, + title = "Episodes", + description = "Seasons and episode list for series.", + ), + MetaScreenSectionDefinition( + key = MetaScreenSectionKey.DETAILS, + title = "Details", + description = "Runtime, status, release, language, and related info.", + ), + MetaScreenSectionDefinition( + key = MetaScreenSectionKey.COLLECTION, + title = "Collection", + description = "Related collection or franchise rail.", + ), + MetaScreenSectionDefinition( + key = MetaScreenSectionKey.MORE_LIKE_THIS, + title = "More Like This", + description = "Recommendation rail.", + ), + ) + + private val _uiState = MutableStateFlow(MetaScreenSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + private var preferences: MutableMap = mutableMapOf() + + fun ensureLoaded() { + if (hasLoaded) return + hasLoaded = true + + val payload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim() + if (payload.isNotEmpty()) { + val parsed = runCatching { + json.decodeFromString(payload) + }.getOrNull() + if (parsed != null) { + preferences = parsed.items.mapNotNull { item -> + val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null + key to item + }.toMap().toMutableMap() + } + } + + normalizePreferences() + publish() + persist() + } + + fun onProfileChanged() { + hasLoaded = false + preferences.clear() + _uiState.value = MetaScreenSettingsUiState() + ensureLoaded() + } + + fun clearLocalState() { + hasLoaded = false + preferences.clear() + _uiState.value = MetaScreenSettingsUiState() + } + + fun setEnabled(key: MetaScreenSectionKey, enabled: Boolean) { + updatePreference(key) { preference -> + preference.copy(enabled = enabled) + } + } + + fun resetToDefaults() { + ensureLoaded() + preferences.clear() + normalizePreferences() + publish() + persist() + } + + fun moveByIndex(fromIndex: Int, toIndex: Int) { + ensureLoaded() + val orderedKeys = definitions + .sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE } + .map { it.key } + .toMutableList() + if (fromIndex !in orderedKeys.indices || toIndex !in orderedKeys.indices) return + if (fromIndex == toIndex) return + orderedKeys.add(toIndex, orderedKeys.removeAt(fromIndex)) + orderedKeys.forEachIndexed { newIndex, sectionKey -> + val current = preferences[sectionKey] ?: return@forEachIndexed + preferences[sectionKey] = current.copy(order = newIndex) + } + publish() + persist() + } + + private fun updatePreference( + key: MetaScreenSectionKey, + transform: (StoredMetaScreenSectionPreference) -> StoredMetaScreenSectionPreference, + ) { + ensureLoaded() + val current = preferences[key] ?: return + preferences[key] = transform(current) + publish() + persist() + } + + private fun normalizePreferences() { + val normalized = mutableMapOf() + definitions.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE } + .forEachIndexed { index, definition -> + val stored = preferences[definition.key] + normalized[definition.key] = StoredMetaScreenSectionPreference( + key = definition.key.name, + enabled = stored?.enabled ?: true, + order = index, + ) + } + preferences = normalized + } + + private fun publish() { + _uiState.value = MetaScreenSettingsUiState( + items = definitions + .sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE } + .map { definition -> + val preference = preferences[definition.key] + MetaScreenSectionItem( + key = definition.key, + title = definition.title, + description = definition.description, + enabled = preference?.enabled ?: true, + order = preference?.order ?: 0, + ) + }, + ) + } + + private fun persist() { + MetaScreenSettingsStorage.savePayload( + json.encodeToString( + StoredMetaScreenSettingsPayload( + items = preferences.values.sortedBy { it.order }, + ), + ), + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.kt new file mode 100644 index 000000000..7ee3c8aaa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.details + +internal expect object MetaScreenSettingsStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt index b1672f95c..1668870e9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt @@ -152,6 +152,16 @@ object HomeCatalogSettingsRepository { } } + fun resetToDefaults() { + ensureLoaded() + heroEnabled = true + preferences.clear() + normalizePreferences() + publish() + persist() + HomeRepository.applyCurrentSettings() + } + fun moveUp(key: String) { move(key = key, direction = -1) } @@ -160,6 +170,25 @@ object HomeCatalogSettingsRepository { move(key = key, direction = 1) } + fun moveByIndex(fromIndex: Int, toIndex: Int) { + ensureLoaded() + if (definitions.isEmpty()) return + val orderedKeys = definitions + .sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE } + .map { it.key } + .toMutableList() + if (fromIndex !in orderedKeys.indices || toIndex !in orderedKeys.indices) return + if (fromIndex == toIndex) return + orderedKeys.add(toIndex, orderedKeys.removeAt(fromIndex)) + orderedKeys.forEachIndexed { index, itemKey -> + val current = preferences[itemKey] ?: return@forEachIndexed + preferences[itemKey] = current.copy(order = index) + } + publish() + persist() + HomeRepository.applyCurrentSettings() + } + private fun ensureLoaded() { if (hasLoaded) return hasLoaded = true diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 1279c8a94..bf86750a8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -5,6 +5,7 @@ import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository @@ -127,6 +128,7 @@ object ProfileRepository { ThemeSettingsRepository.onProfileChanged() PlayerSettingsRepository.onProfileChanged() HomeCatalogSettingsRepository.onProfileChanged() + MetaScreenSettingsRepository.onProfileChanged() ContinueWatchingPreferencesRepository.onProfileChanged() TmdbSettingsRepository.onProfileChanged() MdbListSettingsRepository.onProfileChanged() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt index 666dd9431..57d083ff8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt @@ -11,6 +11,7 @@ internal fun LazyListScope.contentDiscoveryContent( onAddonsClick: () -> Unit, onPluginsClick: () -> Unit, onHomescreenClick: () -> Unit, + onMetaScreenClick: () -> Unit, ) { item { SettingsSection( @@ -48,6 +49,13 @@ internal fun LazyListScope.contentDiscoveryContent( isTablet = isTablet, onClick = onHomescreenClick, ) + SettingsNavigationRow( + title = "Meta Screen", + description = "Disable detail sections and reorder everything below Hero.", + icon = Icons.Rounded.Tune, + isTablet = isTablet, + onClick = onMetaScreenClick, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt index 08e16ba71..2714e285a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt @@ -1,30 +1,42 @@ package com.nuvio.app.features.settings import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.nuvio.app.features.home.HomeCatalogSettingsItem import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.components.HomeEmptyStateCard +import sh.calvin.reorderable.ReorderableCollectionItemScope +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState internal fun LazyListScope.homescreenSettingsContent( isTablet: Boolean, @@ -86,6 +98,11 @@ internal fun LazyListScope.homescreenSettingsContent( title = "CATALOGS", isTablet = isTablet, ) { + SettingsActionRow( + label = "Reset", + isTablet = isTablet, + onClick = HomeCatalogSettingsRepository::resetToDefaults, + ) HomescreenCatalogList( isTablet = isTablet, items = items, @@ -202,26 +219,46 @@ private fun HomescreenCatalogList( items: List, ) { var expandedKey by remember { mutableStateOf(null) } + val hapticFeedback = LocalHapticFeedback.current + val lazyListState = rememberLazyListState() + val reorderableLazyListState = rememberReorderableLazyListState( + lazyListState = lazyListState, + ) { from, to -> + HomeCatalogSettingsRepository.moveByIndex(from.index, to.index) + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } SettingsGroup(isTablet = isTablet) { - items.forEachIndexed { index, item -> - if (index > 0) { - SettingsGroupDivider(isTablet = isTablet) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = if (isTablet) 760.dp else 560.dp), + state = lazyListState, + ) { + itemsIndexed(items, key = { _, item -> item.key }) { index, item -> + ReorderableItem(reorderableLazyListState, key = item.key) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + + Surface(shadowElevation = elevation) { + Column { + if (index > 0) { + SettingsGroupDivider(isTablet = isTablet) + } + HomescreenCatalogRow( + item = item, + isTablet = isTablet, + expanded = expandedKey == item.key, + onExpandedChange = { shouldExpand -> + expandedKey = if (shouldExpand) item.key else null + }, + onTitleChange = { HomeCatalogSettingsRepository.setCustomTitle(item.key, it) }, + onEnabledChange = { HomeCatalogSettingsRepository.setEnabled(item.key, it) }, + dragHandleScope = this@ReorderableItem, + ) + } + } + } } - HomescreenCatalogRow( - item = item, - isTablet = isTablet, - expanded = expandedKey == item.key, - canMoveUp = index > 0, - canMoveDown = index < items.lastIndex, - onExpandedChange = { shouldExpand -> - expandedKey = if (shouldExpand) item.key else null - }, - onTitleChange = { HomeCatalogSettingsRepository.setCustomTitle(item.key, it) }, - onEnabledChange = { HomeCatalogSettingsRepository.setEnabled(item.key, it) }, - onMoveUp = { HomeCatalogSettingsRepository.moveUp(item.key) }, - onMoveDown = { HomeCatalogSettingsRepository.moveDown(item.key) }, - ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt new file mode 100644 index 000000000..7de2cc25f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt @@ -0,0 +1,179 @@ +package com.nuvio.app.features.settings + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Menu +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.details.MetaScreenSectionItem +import com.nuvio.app.features.details.MetaScreenSettingsRepository +import com.nuvio.app.features.details.MetaScreenSettingsUiState +import sh.calvin.reorderable.ReorderableCollectionItemScope +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +internal fun LazyListScope.metaScreenSettingsContent( + isTablet: Boolean, + uiState: MetaScreenSettingsUiState, +) { + item { + SettingsSection( + title = "SECTIONS", + isTablet = isTablet, + ) { + SettingsActionRow( + label = "Reset", + isTablet = isTablet, + onClick = MetaScreenSettingsRepository::resetToDefaults, + ) + SettingsGroup(isTablet = isTablet) { + MetaSectionReorderableList( + items = uiState.items, + isTablet = isTablet, + ) + } + } + } +} + +@Composable +private fun MetaSectionReorderableList( + items: List, + isTablet: Boolean, +) { + val hapticFeedback = LocalHapticFeedback.current + val lazyListState = rememberLazyListState() + val reorderableLazyListState = rememberReorderableLazyListState( + lazyListState = lazyListState, + ) { from, to -> + MetaScreenSettingsRepository.moveByIndex(from.index, to.index) + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = if (isTablet) 680.dp else 520.dp), + state = lazyListState, + ) { + itemsIndexed(items, key = { _, item -> item.key.name }) { index, item -> + ReorderableItem(reorderableLazyListState, key = item.key.name) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + + Surface(shadowElevation = elevation) { + Column { + if (index > 0) { + SettingsGroupDivider(isTablet = isTablet) + } + MetaSectionRow( + item = item, + isTablet = isTablet, + onEnabledChange = { MetaScreenSettingsRepository.setEnabled(item.key, it) }, + dragHandleScope = this@ReorderableItem, + ) + } + } + } + } + } +} + +@Composable +private fun MetaSectionRow( + item: MetaScreenSectionItem, + isTablet: Boolean, + onEnabledChange: (Boolean) -> Unit, + dragHandleScope: ReorderableCollectionItemScope, +) { + val horizontalPadding = if (isTablet) 20.dp else 16.dp + val verticalPadding = if (isTablet) 18.dp else 16.dp + val hapticFeedback = LocalHapticFeedback.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = item.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = if (item.enabled) "Visible" else "Hidden", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = item.enabled, + onCheckedChange = onEnabledChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, + uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant, + ), + ) + IconButton( + modifier = with(dragHandleScope) { + Modifier.draggableHandle( + onDragStarted = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDragStopped = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, + ) + }, + onClick = {}, + ) { + Icon( + Icons.Rounded.Menu, + contentDescription = "Reorder", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt index cb7f21c6e..41f585320 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt @@ -19,9 +19,9 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowForward -import androidx.compose.material.icons.rounded.KeyboardArrowDown -import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Menu import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -30,6 +30,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,13 +38,16 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.features.home.HomeCatalogSettingsItem +import sh.calvin.reorderable.ReorderableCollectionItemScope @Composable private fun SettingsCard( @@ -180,6 +184,31 @@ internal fun SettingsSection( } } +@Composable +internal fun SettingsActionRow( + label: String, + isTablet: Boolean, + onClick: () -> Unit, +) { + val horizontalPadding = if (isTablet) 20.dp else 16.dp + val verticalPadding = if (isTablet) 10.dp else 8.dp + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onClick) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + @Composable internal fun SettingsNavigationRow( title: String, @@ -321,16 +350,14 @@ internal fun HomescreenCatalogRow( item: HomeCatalogSettingsItem, isTablet: Boolean, expanded: Boolean, - canMoveUp: Boolean, - canMoveDown: Boolean, onExpandedChange: (Boolean) -> Unit, onTitleChange: (String) -> Unit, onEnabledChange: (Boolean) -> Unit, - onMoveUp: () -> Unit, - onMoveDown: () -> Unit, + dragHandleScope: ReorderableCollectionItemScope, ) { val horizontalPadding = if (isTablet) 20.dp else 16.dp val verticalPadding = if (isTablet) 18.dp else 16.dp + val hapticFeedback = LocalHapticFeedback.current Column( modifier = Modifier @@ -387,11 +414,25 @@ internal fun HomescreenCatalogRow( uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant, ), ) - Icon( - imageVector = if (expanded) Icons.Rounded.KeyboardArrowUp else Icons.Rounded.KeyboardArrowDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + IconButton( + modifier = with(dragHandleScope) { + Modifier.draggableHandle( + onDragStarted = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDragStopped = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, + ) + }, + onClick = {}, + ) { + Icon( + imageVector = Icons.Rounded.Menu, + contentDescription = "Reorder", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } @@ -414,59 +455,7 @@ internal fun HomescreenCatalogRow( disabledContainerColor = MaterialTheme.colorScheme.surface, ), ) - - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - MoveActionChip( - label = "Move Up", - icon = Icons.Rounded.KeyboardArrowUp, - enabled = canMoveUp, - onClick = onMoveUp, - ) - MoveActionChip( - label = "Move Down", - icon = Icons.Rounded.KeyboardArrowDown, - enabled = canMoveDown, - onClick = onMoveDown, - ) - } } } } } - -@Composable -private fun MoveActionChip( - label: String, - icon: ImageVector, - enabled: Boolean, - onClick: () -> Unit, -) { - Surface( - modifier = Modifier - .clickable(enabled = enabled, onClick = onClick) - .alpha(if (enabled) 1f else 0.45f), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f), - shape = RoundedCornerShape(999.dp), - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt index c313b82bf..45a87f898 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -51,6 +52,33 @@ fun HomescreenSettingsScreen( } } +@Composable +fun MetaScreenSettingsScreen( + onBack: () -> Unit, +) { + val metaScreenSettingsUiState by remember { + MetaScreenSettingsRepository.ensureLoaded() + MetaScreenSettingsRepository.uiState + }.collectAsStateWithLifecycle() + + NuvioScreen( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + stickyHeader { + NuvioScreenHeader( + title = "Meta Screen", + onBack = onBack, + ) + } + metaScreenSettingsContent( + isTablet = false, + uiState = metaScreenSettingsUiState, + ) + } +} + @Composable fun ContinueWatchingSettingsScreen( onBack: () -> Unit, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index 689e5b459..f9a48fe10 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -63,6 +63,11 @@ internal enum class SettingsPage( category = SettingsCategory.General, parentPage = ContentDiscovery, ), + MetaScreen( + title = "Meta Screen", + category = SettingsCategory.General, + parentPage = ContentDiscovery, + ), Integrations( title = "Integrations", category = SettingsCategory.General, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 7ead9e0a8..51bee077e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -38,6 +38,8 @@ import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.details.MetaScreenSettingsRepository +import com.nuvio.app.features.details.MetaScreenSettingsUiState import com.nuvio.app.features.home.HomeCatalogSettingsItem import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettings @@ -56,6 +58,7 @@ fun SettingsScreen( modifier: Modifier = Modifier, onSwitchProfile: (() -> Unit)? = null, onHomescreenClick: () -> Unit = {}, + onMetaScreenClick: () -> Unit = {}, onContinueWatchingClick: () -> Unit = {}, onAddonsClick: () -> Unit = {}, onPluginsClick: () -> Unit = {}, @@ -99,6 +102,10 @@ fun SettingsScreen( val homescreenSettingsUiState by remember { HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() + val metaScreenSettingsUiState by remember { + MetaScreenSettingsRepository.ensureLoaded() + MetaScreenSettingsRepository.uiState + }.collectAsStateWithLifecycle() val continueWatchingPreferencesUiState by remember { ContinueWatchingPreferencesRepository.ensureLoaded() ContinueWatchingPreferencesRepository.uiState @@ -141,6 +148,7 @@ fun SettingsScreen( traktCommentsEnabled = traktCommentsEnabled, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, homescreenItems = homescreenSettingsUiState.items, + metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, onSwitchProfile = onSwitchProfile, ) @@ -168,9 +176,11 @@ fun SettingsScreen( traktCommentsEnabled = traktCommentsEnabled, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, homescreenItems = homescreenSettingsUiState.items, + metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenClick, + onMetaScreenClick = onMetaScreenClick, onContinueWatchingClick = onContinueWatchingClick, onAddonsClick = onAddonsClick, onPluginsClick = onPluginsClick, @@ -204,9 +214,11 @@ private fun MobileSettingsScreen( traktCommentsEnabled: Boolean, homescreenHeroEnabled: Boolean, homescreenItems: List, + metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, onSwitchProfile: (() -> Unit)? = null, onHomescreenClick: () -> Unit = {}, + onMetaScreenClick: () -> Unit = {}, onContinueWatchingClick: () -> Unit = {}, onAddonsClick: () -> Unit = {}, onPluginsClick: () -> Unit = {}, @@ -267,6 +279,7 @@ private fun MobileSettingsScreen( onAddonsClick = onAddonsClick, onPluginsClick = onPluginsClick, onHomescreenClick = onHomescreenClick, + onMetaScreenClick = onMetaScreenClick, ) SettingsPage.Addons -> addonsSettingsContent() SettingsPage.Plugins -> pluginsSettingsContent() @@ -275,6 +288,10 @@ private fun MobileSettingsScreen( heroEnabled = homescreenHeroEnabled, items = homescreenItems, ) + SettingsPage.MetaScreen -> metaScreenSettingsContent( + isTablet = false, + uiState = metaScreenSettingsUiState, + ) SettingsPage.Integrations -> integrationsContent( isTablet = false, onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, @@ -322,6 +339,7 @@ private fun TabletSettingsScreen( traktCommentsEnabled: Boolean, homescreenHeroEnabled: Boolean, homescreenItems: List, + metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, onSwitchProfile: (() -> Unit)? = null, ) { @@ -449,6 +467,7 @@ private fun TabletSettingsScreen( onAddonsClick = { openInlinePage(SettingsPage.Addons) }, onPluginsClick = { openInlinePage(SettingsPage.Plugins) }, onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) }, + onMetaScreenClick = { openInlinePage(SettingsPage.MetaScreen) }, ) SettingsPage.Addons -> addonsSettingsContent() SettingsPage.Plugins -> pluginsSettingsContent() @@ -457,6 +476,10 @@ private fun TabletSettingsScreen( heroEnabled = homescreenHeroEnabled, items = homescreenItems, ) + SettingsPage.MetaScreen -> metaScreenSettingsContent( + isTablet = true, + uiState = metaScreenSettingsUiState, + ) SettingsPage.Integrations -> integrationsContent( isTablet = true, onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.ios.kt new file mode 100644 index 000000000..379562f01 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsStorage.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.details + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +internal actual object MetaScreenSettingsStorage { + private const val payloadKey = "meta_screen_settings_payload" + + actual fun loadPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey)) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 93389e427..c6111a09e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ androidx-media3 = "1.10.0-rc01" supabase = "3.4.1" quickjsKt = "1.0.1" ksoup = "0.2.6" +reorderable = "3.0.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -66,6 +67,7 @@ supabase-auth = { module = "io.github.jan-tennert.supabase:auth-kt", version.ref supabase-functions = { module = "io.github.jan-tennert.supabase:functions-kt", version.ref = "supabase" } quickjs-kt = { module = "io.github.dokar3:quickjs-kt", version.ref = "quickjsKt" } ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } +reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }