From 6d0a2983a2bf1f31ac2353d8578002dd511e66ac Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 10:27:07 +0500 Subject: [PATCH 1/8] test(channels): define manage channels view model contract --- .../channels/ManageChannelsViewModelTest.kt | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsViewModelTest.kt diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsViewModelTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsViewModelTest.kt new file mode 100644 index 000000000..d465d017d --- /dev/null +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsViewModelTest.kt @@ -0,0 +1,173 @@ +package app.muxtv.feature.channels + +import androidx.paging.PagingData +import app.muxtv.catalog.ChannelBrowseItem +import app.muxtv.catalog.ChannelBrowseQuery +import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelFavoriteMutationResult +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.yield +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ManageChannelsViewModelTest { + @Before + fun setUp() = Dispatchers.setMain(Dispatchers.Unconfined) + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun managementFiltersCreateIndependentQueriesLazily() = runBlocking { + val browse = RecordingBrowseRepository() + val viewModel = ManageChannelsViewModel( + channelBrowseRepository = browse, + channelPreferencesRepository = RecordingPreferencesRepository(), + profileId = PROFILE_ID, + ) + + assertThat(browse.managementQueries).isEmpty() + + val allRows = viewModel.rowsFor(ManageChannelsFilter.ALL) + awaitQueryCount(browse, 1) + assertThat(viewModel.rowsFor(ManageChannelsFilter.ALL)).isSameInstanceAs(allRows) + + viewModel.setFilter(ManageChannelsFilter.VISIBLE) + viewModel.rowsFor(viewModel.filter.value) + awaitQueryCount(browse, 2) + + viewModel.setFilter(ManageChannelsFilter.HIDDEN) + viewModel.rowsFor(viewModel.filter.value) + awaitQueryCount(browse, 3) + + assertThat(browse.managementQueries.map(ChannelManagementQuery::visibility)) + .containsExactly( + ChannelManagementVisibility.ALL, + ChannelManagementVisibility.VISIBLE, + ChannelManagementVisibility.HIDDEN, + ).inOrder() + assertThat(browse.managementQueries.all { it.profileId == PROFILE_ID }).isTrue() + } + + @Test + fun mutationsUseProfileScopedCanonicalIdentityAndExposeResult() = runBlocking { + val preferences = RecordingPreferencesRepository() + val viewModel = ManageChannelsViewModel( + channelBrowseRepository = RecordingBrowseRepository(), + channelPreferencesRepository = preferences, + profileId = PROFILE_ID, + ) + + viewModel.setHidden("channel-1", true) + awaitMutationCount(preferences, 1) + assertThat(preferences.calls.last()).isEqualTo("hidden:$PROFILE_ID:channel-1:true") + assertThat(viewModel.lastMutationResult.value).isEqualTo(ChannelPreferenceMutationResult.Applied) + + preferences.nextResult = ChannelPreferenceMutationResult.InvalidInput + viewModel.setCustomName("channel-1", " ") + awaitMutationCount(preferences, 2) + assertThat(preferences.calls.last()).isEqualTo("name:$PROFILE_ID:channel-1: ") + assertThat(viewModel.lastMutationResult.value).isEqualTo(ChannelPreferenceMutationResult.InvalidInput) + + preferences.nextResult = ChannelPreferenceMutationResult.Applied + viewModel.setChannelNumber("channel-1", 42) + awaitMutationCount(preferences, 3) + assertThat(preferences.calls.last()).isEqualTo("number:$PROFILE_ID:channel-1:42") + + viewModel.resetCustomization("channel-1") + awaitMutationCount(preferences, 4) + assertThat(preferences.calls.last()).isEqualTo("reset:$PROFILE_ID:channel-1") + } + + private suspend fun awaitQueryCount(repository: RecordingBrowseRepository, count: Int) { + repeat(100) { + if (repository.managementQueries.size >= count) return + yield() + } + error("Expected $count management queries, got ${repository.managementQueries.size}") + } + + private suspend fun awaitMutationCount(repository: RecordingPreferencesRepository, count: Int) { + repeat(100) { + if (repository.calls.size >= count) return + yield() + } + error("Expected $count preference mutations, got ${repository.calls.size}") + } + + private class RecordingBrowseRepository : ChannelBrowseRepository { + val managementQueries = mutableListOf() + + override fun pages(query: ChannelBrowseQuery): Flow> = + flowOf(PagingData.empty()) + + override fun managementPages(query: ChannelManagementQuery): Flow> { + managementQueries += query + return flowOf(PagingData.empty()) + } + } + + private class RecordingPreferencesRepository : ChannelPreferencesRepository { + val calls = mutableListOf() + var nextResult: ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.Applied + + override suspend fun setFavorite( + profileId: String, + channelId: String, + isFavorite: Boolean, + ): ChannelFavoriteMutationResult = ChannelFavoriteMutationResult.Unchanged + + override suspend fun setHidden( + profileId: String, + channelId: String, + isHidden: Boolean, + ): ChannelPreferenceMutationResult { + calls += "hidden:$profileId:$channelId:$isHidden" + return nextResult + } + + override suspend fun setCustomName( + profileId: String, + channelId: String, + customName: String?, + ): ChannelPreferenceMutationResult { + calls += "name:$profileId:$channelId:$customName" + return nextResult + } + + override suspend fun setChannelNumber( + profileId: String, + channelId: String, + channelNumber: Int?, + ): ChannelPreferenceMutationResult { + calls += "number:$profileId:$channelId:$channelNumber" + return nextResult + } + + override suspend fun resetCustomization( + profileId: String, + channelId: String, + ): ChannelPreferenceMutationResult { + calls += "reset:$profileId:$channelId" + return nextResult + } + } + + private companion object { + const val PROFILE_ID = "profile-main" + } +} From ba73b517bcc1b27a54ae6eca80b6bd755c13224f Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 10:28:55 +0500 Subject: [PATCH 2/8] feat(channels): add manage channels view model --- .../channels/ManageChannelsViewModel.kt | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt new file mode 100644 index 000000000..0c92d1f54 --- /dev/null +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt @@ -0,0 +1,119 @@ +package app.muxtv.feature.channels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelManagementItem +import app.muxtv.catalog.ChannelManagementQuery +import app.muxtv.catalog.ChannelManagementVisibility +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal enum class ManageChannelsFilter { + ALL, + VISIBLE, + HIDDEN, +} + +internal class ManageChannelsViewModel( + private val channelBrowseRepository: ChannelBrowseRepository, + private val channelPreferencesRepository: ChannelPreferencesRepository, + private val profileId: String, +) : ViewModel() { + private val mutableFilter = MutableStateFlow(ManageChannelsFilter.ALL) + val filter: StateFlow = mutableFilter.asStateFlow() + + private val mutableLastMutationResult = MutableStateFlow(null) + val lastMutationResult: StateFlow = + mutableLastMutationResult.asStateFlow() + + private val rowsByFilter = mutableMapOf>>() + private val mutationMutex = Mutex() + + init { + require(profileId.isNotBlank()) + } + + fun rowsFor(filter: ManageChannelsFilter): Flow> = + rowsByFilter.getOrPut(filter) { + channelBrowseRepository.managementPages( + ChannelManagementQuery( + profileId = profileId, + visibility = filter.toManagementVisibility(), + ), + ).cachedIn(viewModelScope) + } + + fun setFilter(filter: ManageChannelsFilter) { + mutableFilter.value = filter + } + + fun setHidden(channelId: String, isHidden: Boolean) { + mutate(channelId) { + channelPreferencesRepository.setHidden( + profileId = profileId, + channelId = channelId, + isHidden = isHidden, + ) + } + } + + fun setCustomName(channelId: String, customName: String?) { + mutate(channelId) { + channelPreferencesRepository.setCustomName( + profileId = profileId, + channelId = channelId, + customName = customName, + ) + } + } + + fun setChannelNumber(channelId: String, channelNumber: Int?) { + mutate(channelId) { + channelPreferencesRepository.setChannelNumber( + profileId = profileId, + channelId = channelId, + channelNumber = channelNumber, + ) + } + } + + fun resetCustomization(channelId: String) { + mutate(channelId) { + channelPreferencesRepository.resetCustomization( + profileId = profileId, + channelId = channelId, + ) + } + } + + fun clearMutationResult() { + mutableLastMutationResult.value = null + } + + private fun mutate( + channelId: String, + mutation: suspend () -> ChannelPreferenceMutationResult, + ) { + require(channelId.isNotBlank()) + viewModelScope.launch { + val result = mutationMutex.withLock { mutation() } + mutableLastMutationResult.value = result + } + } +} + +private fun ManageChannelsFilter.toManagementVisibility(): ChannelManagementVisibility = when (this) { + ManageChannelsFilter.ALL -> ChannelManagementVisibility.ALL + ManageChannelsFilter.VISIBLE -> ChannelManagementVisibility.VISIBLE + ManageChannelsFilter.HIDDEN -> ChannelManagementVisibility.HIDDEN +} From bb17074d7ca2d4ba9fe2da48ffe6da285f5bc664 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 10:29:15 +0500 Subject: [PATCH 3/8] test(channels): define manage channel presentation contract --- .../ManageChannelsPresentationTest.kt | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsPresentationTest.kt diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsPresentationTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsPresentationTest.kt new file mode 100644 index 000000000..70bc6263c --- /dev/null +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsPresentationTest.kt @@ -0,0 +1,56 @@ +package app.muxtv.feature.channels + +import app.muxtv.catalog.ChannelManagementItem +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ManageChannelsPresentationTest { + @Test + fun customizedRowKeepsOriginalAndEffectiveValuesDistinct() { + val row = buildManageChannelRow( + ChannelManagementItem( + channelId = "channel-1", + canonicalDisplayName = "Discovery Channel HD", + effectiveDisplayName = "Discovery", + defaultChannelNumber = "501", + customChannelNumber = 7, + effectiveChannelNumber = "7", + isFavorite = true, + isHidden = true, + variantCount = 2, + ), + ) + + assertThat(row.channelId).isEqualTo("channel-1") + assertThat(row.displayName).isEqualTo("Discovery") + assertThat(row.originalDisplayName).isEqualTo("Discovery Channel HD") + assertThat(row.channelNumber).isEqualTo("7") + assertThat(row.defaultChannelNumber).isEqualTo("501") + assertThat(row.hasCustomName).isTrue() + assertThat(row.hasCustomNumber).isTrue() + assertThat(row.isFavorite).isTrue() + assertThat(row.isHidden).isTrue() + assertThat(row.variantCount).isEqualTo(2) + } + + @Test + fun untouchedRowDoesNotPretendToBeCustomized() { + val row = buildManageChannelRow( + ChannelManagementItem( + channelId = "channel-2", + canonicalDisplayName = "News", + effectiveDisplayName = "News", + defaultChannelNumber = "12", + customChannelNumber = null, + effectiveChannelNumber = "12", + isFavorite = false, + isHidden = false, + variantCount = 1, + ), + ) + + assertThat(row.originalDisplayName).isNull() + assertThat(row.hasCustomName).isFalse() + assertThat(row.hasCustomNumber).isFalse() + } +} From 8ac1125d50b54b06131f22587b20def5130cf397 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 10:29:28 +0500 Subject: [PATCH 4/8] feat(channels): add manage channel presentation model --- .../channels/ManageChannelsPresentation.kt | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsPresentation.kt diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsPresentation.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsPresentation.kt new file mode 100644 index 000000000..3b14dcc80 --- /dev/null +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsPresentation.kt @@ -0,0 +1,32 @@ +package app.muxtv.feature.channels + +import app.muxtv.catalog.ChannelManagementItem + +internal data class ManageChannelRowUiModel( + val channelId: String, + val displayName: String, + val originalDisplayName: String?, + val channelNumber: String?, + val defaultChannelNumber: String?, + val hasCustomName: Boolean, + val hasCustomNumber: Boolean, + val isFavorite: Boolean, + val isHidden: Boolean, + val variantCount: Int, +) + +internal fun buildManageChannelRow(item: ChannelManagementItem): ManageChannelRowUiModel { + val hasCustomName = item.effectiveDisplayName != item.canonicalDisplayName + return ManageChannelRowUiModel( + channelId = item.channelId, + displayName = item.effectiveDisplayName, + originalDisplayName = item.canonicalDisplayName.takeIf { hasCustomName }, + channelNumber = item.effectiveChannelNumber, + defaultChannelNumber = item.defaultChannelNumber, + hasCustomName = hasCustomName, + hasCustomNumber = item.customChannelNumber != null, + isFavorite = item.isFavorite, + isHidden = item.isHidden, + variantCount = item.variantCount, + ) +} From d2f8e837ad675721291a61ff68efefcd743d2d25 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 10:29:47 +0500 Subject: [PATCH 5/8] refactor(channels): expose manage channel presentation rows --- .../muxtv/feature/channels/ManageChannelsViewModel.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt index 0c92d1f54..9bb9afa3f 100644 --- a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsViewModel.kt @@ -4,8 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn +import androidx.paging.map import app.muxtv.catalog.ChannelBrowseRepository -import app.muxtv.catalog.ChannelManagementItem import app.muxtv.catalog.ChannelManagementQuery import app.muxtv.catalog.ChannelManagementVisibility import app.muxtv.catalog.ChannelPreferenceMutationResult @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -36,21 +37,23 @@ internal class ManageChannelsViewModel( val lastMutationResult: StateFlow = mutableLastMutationResult.asStateFlow() - private val rowsByFilter = mutableMapOf>>() + private val rowsByFilter = mutableMapOf>>() private val mutationMutex = Mutex() init { require(profileId.isNotBlank()) } - fun rowsFor(filter: ManageChannelsFilter): Flow> = + fun rowsFor(filter: ManageChannelsFilter): Flow> = rowsByFilter.getOrPut(filter) { channelBrowseRepository.managementPages( ChannelManagementQuery( profileId = profileId, visibility = filter.toManagementVisibility(), ), - ).cachedIn(viewModelScope) + ).map { pagingData -> + pagingData.map(::buildManageChannelRow) + }.cachedIn(viewModelScope) } fun setFilter(filter: ManageChannelsFilter) { From b96821e34fdcd03f2d16850585dd9a99d21aa6a2 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 10:33:04 +0500 Subject: [PATCH 6/8] fix(channels): keep custom number distinct in manage UI --- .../app/muxtv/feature/channels/ManageChannelsPresentation.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsPresentation.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsPresentation.kt index 3b14dcc80..73b45cee6 100644 --- a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsPresentation.kt +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsPresentation.kt @@ -8,6 +8,7 @@ internal data class ManageChannelRowUiModel( val originalDisplayName: String?, val channelNumber: String?, val defaultChannelNumber: String?, + val customChannelNumber: Int?, val hasCustomName: Boolean, val hasCustomNumber: Boolean, val isFavorite: Boolean, @@ -23,6 +24,7 @@ internal fun buildManageChannelRow(item: ChannelManagementItem): ManageChannelRo originalDisplayName = item.canonicalDisplayName.takeIf { hasCustomName }, channelNumber = item.effectiveChannelNumber, defaultChannelNumber = item.defaultChannelNumber, + customChannelNumber = item.customChannelNumber, hasCustomName = hasCustomName, hasCustomNumber = item.customChannelNumber != null, isFavorite = item.isFavorite, From 586cb2f928f15290ff5dc56df613e250d677f9bd Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 10:33:19 +0500 Subject: [PATCH 7/8] test(channels): keep custom channel number explicit --- .../muxtv/feature/channels/ManageChannelsPresentationTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsPresentationTest.kt b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsPresentationTest.kt index 70bc6263c..cb7ebf908 100644 --- a/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsPresentationTest.kt +++ b/feature/channels/src/test/kotlin/app/muxtv/feature/channels/ManageChannelsPresentationTest.kt @@ -26,6 +26,7 @@ class ManageChannelsPresentationTest { assertThat(row.originalDisplayName).isEqualTo("Discovery Channel HD") assertThat(row.channelNumber).isEqualTo("7") assertThat(row.defaultChannelNumber).isEqualTo("501") + assertThat(row.customChannelNumber).isEqualTo(7) assertThat(row.hasCustomName).isTrue() assertThat(row.hasCustomNumber).isTrue() assertThat(row.isFavorite).isTrue() @@ -50,6 +51,7 @@ class ManageChannelsPresentationTest { ) assertThat(row.originalDisplayName).isNull() + assertThat(row.customChannelNumber).isNull() assertThat(row.hasCustomName).isFalse() assertThat(row.hasCustomNumber).isFalse() } From f2993c70a317b6db333f3c2fcdf969946d359a86 Mon Sep 17 00:00:00 2001 From: MrFr3di Date: Mon, 31 Aug 2026 10:34:35 +0500 Subject: [PATCH 8/8] feat(channels): add manage channels TV surface --- .../feature/channels/ManageChannelsRoute.kt | 689 ++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100644 feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt diff --git a/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt new file mode 100644 index 000000000..115dae677 --- /dev/null +++ b/feature/channels/src/main/kotlin/app/muxtv/feature/channels/ManageChannelsRoute.kt @@ -0,0 +1,689 @@ +package app.muxtv.feature.channels + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemContentType +import androidx.paging.compose.itemKey +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import app.muxtv.catalog.ChannelBrowseRepository +import app.muxtv.catalog.ChannelPreferenceMutationResult +import app.muxtv.catalog.ChannelPreferencesRepository +import app.muxtv.designsystem.TvTokens +import app.muxtv.designsystem.component.MuxTvActionButton +import app.muxtv.designsystem.component.MuxTvScreenScaffold +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first + +@Composable +fun ManageChannelsRoute( + channelBrowseRepository: ChannelBrowseRepository, + channelPreferencesRepository: ChannelPreferencesRepository, + profileId: String, + modifier: Modifier = Modifier, + railFocusRequester: FocusRequester? = null, +) { + val factory = remember(channelBrowseRepository, channelPreferencesRepository, profileId) { + viewModelFactory { + initializer { + ManageChannelsViewModel( + channelBrowseRepository = channelBrowseRepository, + channelPreferencesRepository = channelPreferencesRepository, + profileId = profileId, + ) + } + } + } + val screenViewModel: ManageChannelsViewModel = viewModel(factory = factory) + val filter by screenViewModel.filter.collectAsStateWithLifecycle() + val mutationResult by screenViewModel.lastMutationResult.collectAsStateWithLifecycle() + val rowsFlow = remember(screenViewModel, filter) { screenViewModel.rowsFor(filter) } + val rows = rowsFlow.collectAsLazyPagingItems() + val listState = rememberLazyListState() + + var focusedChannelId by rememberSaveable { mutableStateOf(null) } + var focusedChannelIndex by rememberSaveable { mutableIntStateOf(0) } + var focusedScrollOffset by rememberSaveable { mutableIntStateOf(0) } + var recoverySignal by rememberSaveable { mutableIntStateOf(0) } + var selectedRow by remember { mutableStateOf(null) } + var editor by remember { mutableStateOf(null) } + var editorPending by remember { mutableStateOf(false) } + var editorError by remember { mutableStateOf(null) } + var statusMessage by remember { mutableStateOf(null) } + + val focusAnchor = focusedChannelId?.let { id -> + FocusAnchor(id, focusedChannelIndex, focusedScrollOffset) + } + + LaunchedEffect(mutationResult, editorPending) { + val result = mutationResult ?: return@LaunchedEffect + if (editorPending) { + when (result) { + ChannelPreferenceMutationResult.Applied, + ChannelPreferenceMutationResult.Unchanged, + -> { + editor = null + editorPending = false + editorError = null + } + + ChannelPreferenceMutationResult.InvalidInput -> { + editorPending = false + editorError = "Проверьте введённое значение." + } + + ChannelPreferenceMutationResult.NotFound -> { + editor = null + editorPending = false + editorError = null + statusMessage = "Канал больше недоступен." + } + } + screenViewModel.clearMutationResult() + } else { + if (result == ChannelPreferenceMutationResult.NotFound) { + statusMessage = "Канал больше недоступен." + } + screenViewModel.clearMutationResult() + } + } + + ManageChannelsContent( + rows = rows, + filter = filter, + listState = listState, + focusAnchor = focusAnchor, + recoverySignal = recoverySignal, + statusMessage = statusMessage, + onFilterChanged = screenViewModel::setFilter, + onFocusAnchorChanged = { anchor -> + focusedChannelId = anchor.itemKey + focusedChannelIndex = anchor.previousIndex + focusedScrollOffset = anchor.scrollOffset + }, + onOpenActions = { row -> + statusMessage = null + screenViewModel.clearMutationResult() + selectedRow = row + }, + railFocusRequester = railFocusRequester, + modifier = modifier, + ) + + selectedRow?.let { row -> + ManageActionsDialog( + row = row, + onToggleHidden = { + val disappears = when (filter) { + ManageChannelsFilter.ALL -> false + ManageChannelsFilter.VISIBLE -> !row.isHidden + ManageChannelsFilter.HIDDEN -> row.isHidden + } + if (disappears) recoverySignal += 1 + selectedRow = null + screenViewModel.clearMutationResult() + screenViewModel.setHidden(row.channelId, !row.isHidden) + }, + onRename = { + selectedRow = null + editorError = null + screenViewModel.clearMutationResult() + editor = ManageEditorState( + row = row, + mode = ManageEditorMode.NAME, + value = row.displayName, + ) + }, + onChangeNumber = { + selectedRow = null + editorError = null + screenViewModel.clearMutationResult() + editor = ManageEditorState( + row = row, + mode = ManageEditorMode.NUMBER, + value = row.customChannelNumber?.toString().orEmpty(), + ) + }, + onReset = { + if (filter == ManageChannelsFilter.HIDDEN && row.isHidden) recoverySignal += 1 + selectedRow = null + screenViewModel.clearMutationResult() + screenViewModel.resetCustomization(row.channelId) + }, + onDismiss = { selectedRow = null }, + ) + } + + editor?.let { current -> + ManageEditorDialog( + state = current, + pending = editorPending, + errorMessage = editorError, + onValueChange = { value -> + editorError = null + editor = current.copy( + value = when (current.mode) { + ManageEditorMode.NAME -> value + ManageEditorMode.NUMBER -> value.filter(Char::isDigit).take(4) + }, + ) + }, + onSave = { + editorError = null + screenViewModel.clearMutationResult() + when (current.mode) { + ManageEditorMode.NAME -> { + editorPending = true + screenViewModel.setCustomName(current.row.channelId, current.value) + } + + ManageEditorMode.NUMBER -> { + val number = current.value.takeIf(String::isNotBlank)?.toIntOrNull() + if (current.value.isNotBlank() && number == null) { + editorError = "Введите номер от 1 до 9999." + } else { + editorPending = true + screenViewModel.setChannelNumber(current.row.channelId, number) + } + } + } + }, + onRestoreDefault = { + editorError = null + screenViewModel.clearMutationResult() + editorPending = true + when (current.mode) { + ManageEditorMode.NAME -> screenViewModel.setCustomName(current.row.channelId, null) + ManageEditorMode.NUMBER -> screenViewModel.setChannelNumber(current.row.channelId, null) + } + }, + onDismiss = { + if (!editorPending) { + editor = null + editorError = null + screenViewModel.clearMutationResult() + } + }, + ) + } +} + +@Composable +private fun ManageChannelsContent( + rows: LazyPagingItems, + filter: ManageChannelsFilter, + listState: LazyListState, + focusAnchor: FocusAnchor?, + recoverySignal: Int, + statusMessage: String?, + onFilterChanged: (ManageChannelsFilter) -> Unit, + onFocusAnchorChanged: (FocusAnchor) -> Unit, + onOpenActions: (ManageChannelRowUiModel) -> Unit, + railFocusRequester: FocusRequester?, + modifier: Modifier, +) { + val focusRequesters = remember { mutableStateMapOf() } + val allFocusRequester = remember { FocusRequester() } + val visibleFocusRequester = remember { FocusRequester() } + val hiddenFocusRequester = remember { FocusRequester() } + val selectedFilterRequester = when (filter) { + ManageChannelsFilter.ALL -> allFocusRequester + ManageChannelsFilter.VISIBLE -> visibleFocusRequester + ManageChannelsFilter.HIDDEN -> hiddenFocusRequester + } + var handledRecoverySignal by remember(filter) { mutableIntStateOf(-1) } + val refreshState = rows.loadState.refresh + + LaunchedEffect(filter, refreshState, rows.itemCount, recoverySignal, focusAnchor) { + if (handledRecoverySignal == recoverySignal || refreshState !is LoadState.NotLoading) { + return@LaunchedEffect + } + if (rows.itemCount == 0) { + withFrameNanos { } + selectedFilterRequester.requestFocus() + handledRecoverySignal = recoverySignal + return@LaunchedEffect + } + + val loadedKeys = (0 until rows.itemCount).mapNotNull { index -> rows.peek(index)?.channelId } + if (loadedKeys.isEmpty()) return@LaunchedEffect + val target = focusAnchor?.resolveAgainst(loadedKeys) + ?: FocusTarget(loadedKeys.first(), 0, 0) + listState.scrollToItem(target.index.coerceAtMost(rows.itemCount - 1), target.scrollOffset) + val requester = snapshotFlow { focusRequesters[target.itemKey] } + .filterNotNull() + .first() + withFrameNanos { } + if (requester.requestFocus()) handledRecoverySignal = recoverySignal + } + + MuxTvScreenScaffold( + title = "Управление каналами", + modifier = modifier, + titleTestTag = MANAGE_CHANNELS_TITLE_TEST_TAG, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { + MuxTvActionButton( + text = "Все", + onClick = { onFilterChanged(ManageChannelsFilter.ALL) }, + selected = filter == ManageChannelsFilter.ALL, + modifier = Modifier + .testTag(MANAGE_ALL_FILTER_TEST_TAG) + .focusRequester(allFocusRequester) + .focusProperties { + left = railFocusRequester ?: FocusRequester.Default + right = visibleFocusRequester + }, + ) + MuxTvActionButton( + text = "Видимые", + onClick = { onFilterChanged(ManageChannelsFilter.VISIBLE) }, + selected = filter == ManageChannelsFilter.VISIBLE, + modifier = Modifier + .testTag(MANAGE_VISIBLE_FILTER_TEST_TAG) + .focusRequester(visibleFocusRequester) + .focusProperties { + left = allFocusRequester + right = hiddenFocusRequester + }, + ) + MuxTvActionButton( + text = "Скрытые", + onClick = { onFilterChanged(ManageChannelsFilter.HIDDEN) }, + selected = filter == ManageChannelsFilter.HIDDEN, + modifier = Modifier + .testTag(MANAGE_HIDDEN_FILTER_TEST_TAG) + .focusRequester(hiddenFocusRequester) + .focusProperties { left = visibleFocusRequester }, + ) + } + + statusMessage?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + + when { + refreshState is LoadState.Loading && rows.itemCount == 0 -> + Text("Загрузка каналов…", color = MaterialTheme.colorScheme.onSurfaceVariant) + + refreshState is LoadState.Error && rows.itemCount == 0 -> + MuxTvActionButton(text = "Повторить", onClick = rows::retry) + + refreshState is LoadState.NotLoading && rows.itemCount == 0 -> + Text(filter.emptyMessage(), color = MaterialTheme.colorScheme.onSurfaceVariant) + + else -> { + Text( + text = filter.countLabel(rows.itemCount), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small), + ) { + items( + count = rows.itemCount, + key = rows.itemKey(ManageChannelRowUiModel::channelId), + contentType = rows.itemContentType { "manage-channel-row" }, + ) { index -> + val row = rows[index] + if (row == null) { + Text("Загрузка…") + } else { + val focusRequester = remember(row.channelId) { FocusRequester() } + DisposableEffect(row.channelId, focusRequester) { + focusRequesters[row.channelId] = focusRequester + onDispose { + if (focusRequesters[row.channelId] === focusRequester) { + focusRequesters.remove(row.channelId) + } + } + } + fun captureAnchor() = onFocusAnchorChanged( + FocusAnchor( + itemKey = row.channelId, + previousIndex = index, + scrollOffset = listState.firstVisibleItemScrollOffset, + ), + ) + ManageChannelRow( + row = row, + onClick = { + captureAnchor() + onOpenActions(row) + }, + modifier = Modifier + .fillMaxWidth() + .testTag("$MANAGE_ROW_TEST_TAG_PREFIX$index") + .focusRequester(focusRequester) + .focusProperties { + if (index == 0) { + up = selectedFilterRequester + left = railFocusRequester ?: FocusRequester.Default + } + } + .onFocusChanged { state -> + if (state.isFocused) captureAnchor() + }, + ) + } + } + if (rows.loadState.append is LoadState.Error) { + item(key = "manage-append-error") { + MuxTvActionButton(text = "Повторить загрузку", onClick = rows::retry) + } + } + } + } + } + } +} + +@Composable +private fun ManageChannelRow( + row: ManageChannelRowUiModel, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(TvTokens.Shape.rowCorner) + val metadata = buildList { + if (row.isHidden) add("Скрыт") else add("Виден") + if (row.isFavorite) add("Избранное") + if (row.hasCustomName) add("Имя изменено") + if (row.hasCustomNumber) add("Номер изменён") + if (row.variantCount > 1) add("${row.variantCount} источника") + }.joinToString(" · ") + + Row( + modifier = modifier + .height(TvTokens.Size.channelRowHeight) + .clip(shape) + .background(if (focused) TvTokens.Color.surfaceRaised else MaterialTheme.colorScheme.surface) + .border( + width = if (focused) TvTokens.Focus.outlineWidth else 1.dp, + color = if (focused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.borderVariant, + shape = shape, + ) + .onFocusChanged { focused = it.isFocused } + .clickable(role = Role.Button, onClick = onClick) + .focusable() + .padding(horizontal = TvTokens.Spacing.medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.width(72.dp), contentAlignment = Alignment.Center) { + Text( + text = row.channelNumber?.takeIf(String::isNotBlank) ?: "—", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(TvTokens.Spacing.small)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = row.displayName, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = row.originalDisplayName?.let { "Исходное имя: $it" } ?: metadata, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (row.originalDisplayName != null) { + Spacer(Modifier.width(TvTokens.Spacing.medium)) + Text( + text = metadata, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + if (row.hasCustomNumber) { + Spacer(Modifier.width(TvTokens.Spacing.medium)) + Text( + text = "По умолчанию: ${row.defaultChannelNumber ?: "—"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ManageActionsDialog( + row: ManageChannelRowUiModel, + onToggleHidden: () -> Unit, + onRename: () -> Unit, + onChangeNumber: () -> Unit, + onReset: () -> Unit, + onDismiss: () -> Unit, +) { + val firstFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + withFrameNanos { } + firstFocusRequester.requestFocus() + } + Dialog(onDismissRequest = onDismiss) { + ModalCard(title = row.displayName) { + MuxTvActionButton( + text = if (row.isHidden) "Показать канал" else "Скрыть канал", + onClick = onToggleHidden, + modifier = Modifier + .testTag(MANAGE_TOGGLE_HIDDEN_TEST_TAG) + .focusRequester(firstFocusRequester), + ) + MuxTvActionButton(text = "Переименовать", onClick = onRename) + MuxTvActionButton(text = "Номер канала", onClick = onChangeNumber) + MuxTvActionButton(text = "Сбросить настройки", onClick = onReset) + MuxTvActionButton(text = "Закрыть", onClick = onDismiss) + } + } +} + +@Composable +private fun ManageEditorDialog( + state: ManageEditorState, + pending: Boolean, + errorMessage: String?, + onValueChange: (String) -> Unit, + onSave: () -> Unit, + onRestoreDefault: () -> Unit, + onDismiss: () -> Unit, +) { + val inputFocusRequester = remember(state.mode, state.row.channelId) { FocusRequester() } + LaunchedEffect(state.mode, state.row.channelId) { + withFrameNanos { } + inputFocusRequester.requestFocus() + } + Dialog(onDismissRequest = onDismiss) { + ModalCard( + title = when (state.mode) { + ManageEditorMode.NAME -> "Название канала" + ManageEditorMode.NUMBER -> "Номер канала" + }, + ) { + ManageTextInput( + value = state.value, + onValueChange = onValueChange, + modifier = Modifier + .testTag( + if (state.mode == ManageEditorMode.NAME) { + MANAGE_NAME_INPUT_TEST_TAG + } else { + MANAGE_NUMBER_INPUT_TEST_TAG + }, + ) + .focusRequester(inputFocusRequester), + ) + if (state.mode == ManageEditorMode.NUMBER) { + Text( + text = "Пустое значение вернёт номер провайдера: ${state.row.defaultChannelNumber ?: "—"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + errorMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + } + Row(horizontalArrangement = Arrangement.spacedBy(TvTokens.Spacing.small)) { + MuxTvActionButton( + text = "Сохранить", + onClick = onSave, + enabled = !pending, + modifier = Modifier.testTag(MANAGE_EDITOR_SAVE_TEST_TAG), + ) + MuxTvActionButton( + text = if (state.mode == ManageEditorMode.NAME) { + "Исходное имя" + } else { + "Номер провайдера" + }, + onClick = onRestoreDefault, + enabled = !pending, + ) + MuxTvActionButton(text = "Отмена", onClick = onDismiss, enabled = !pending) + } + } + } +} + +@Composable +private fun ModalCard( + title: String, + content: @Composable Column.() -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth(0.62f) + .clip(RoundedCornerShape(TvTokens.Shape.detailsCorner)) + .background(MaterialTheme.colorScheme.surface) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.border, + shape = RoundedCornerShape(TvTokens.Shape.detailsCorner), + ) + .padding(TvTokens.Spacing.large), + verticalArrangement = Arrangement.spacedBy(TvTokens.Spacing.medium), + ) { + Text(title, style = MaterialTheme.typography.headlineSmall) + content() + } +} + +@Composable +private fun ManageTextInput( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var focused by remember { mutableStateOf(false) } + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier + .fillMaxWidth() + .onFocusChanged { focused = it.isFocused } + .background( + if (focused) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant, + ) + .padding(horizontal = 20.dp, vertical = 16.dp), + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + ) +} + +private enum class ManageEditorMode { + NAME, + NUMBER, +} + +private data class ManageEditorState( + val row: ManageChannelRowUiModel, + val mode: ManageEditorMode, + val value: String, +) + +private fun ManageChannelsFilter.emptyMessage(): String = when (this) { + ManageChannelsFilter.ALL -> "Активных каналов пока нет." + ManageChannelsFilter.VISIBLE -> "Видимых каналов пока нет." + ManageChannelsFilter.HIDDEN -> "Скрытых каналов пока нет." +} + +private fun ManageChannelsFilter.countLabel(count: Int): String = when (this) { + ManageChannelsFilter.ALL -> "Каналов: $count" + ManageChannelsFilter.VISIBLE -> "Видимых: $count" + ManageChannelsFilter.HIDDEN -> "Скрытых: $count" +} + +internal const val MANAGE_CHANNELS_TITLE_TEST_TAG = "manage-channels-title" +internal const val MANAGE_ALL_FILTER_TEST_TAG = "manage-filter-all" +internal const val MANAGE_VISIBLE_FILTER_TEST_TAG = "manage-filter-visible" +internal const val MANAGE_HIDDEN_FILTER_TEST_TAG = "manage-filter-hidden" +internal const val MANAGE_ROW_TEST_TAG_PREFIX = "manage-channel-row-" +internal const val MANAGE_TOGGLE_HIDDEN_TEST_TAG = "manage-toggle-hidden" +internal const val MANAGE_NAME_INPUT_TEST_TAG = "manage-name-input" +internal const val MANAGE_NUMBER_INPUT_TEST_TAG = "manage-number-input" +internal const val MANAGE_EDITOR_SAVE_TEST_TAG = "manage-editor-save"