diff --git a/app/src/main/kotlin/com/nendo/argosy/data/local/dao/PlatformDao.kt b/app/src/main/kotlin/com/nendo/argosy/data/local/dao/PlatformDao.kt index 0f40dd87..0c044930 100644 --- a/app/src/main/kotlin/com/nendo/argosy/data/local/dao/PlatformDao.kt +++ b/app/src/main/kotlin/com/nendo/argosy/data/local/dao/PlatformDao.kt @@ -29,10 +29,10 @@ interface PlatformDao { @Query("SELECT * FROM platforms WHERE gameCount > 0 AND isVisible = 1 AND syncEnabled = 1 ORDER BY sortOrder ASC, name ASC") suspend fun getPlatformsWithGames(): List - @Insert(onConflict = OnConflictStrategy.IGNORE) + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(platform: PlatformEntity) - @Insert(onConflict = OnConflictStrategy.IGNORE) + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(platforms: List) @Update diff --git a/app/src/main/kotlin/com/nendo/argosy/ui/screens/firstrun/FirstRunScreen.kt b/app/src/main/kotlin/com/nendo/argosy/ui/screens/firstrun/FirstRunScreen.kt index 74f02f72..67a97535 100644 --- a/app/src/main/kotlin/com/nendo/argosy/ui/screens/firstrun/FirstRunScreen.kt +++ b/app/src/main/kotlin/com/nendo/argosy/ui/screens/firstrun/FirstRunScreen.kt @@ -68,13 +68,24 @@ import androidx.lifecycle.compose.LifecycleEventEffect import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import com.nendo.argosy.data.local.entity.PlatformEntity import com.nendo.argosy.ui.filebrowser.FileBrowserMode import com.nendo.argosy.ui.filebrowser.FileBrowserScreen import com.nendo.argosy.ui.input.LocalInputDispatcher import com.nendo.argosy.ui.theme.Dimens +import com.nendo.argosy.util.PlatformFilterLogic @Composable fun FirstRunScreen( @@ -218,10 +229,15 @@ fun FirstRunScreen( ) FirstRunStep.PLATFORM_SELECT -> PlatformSelectStep( platforms = uiState.platforms, + hasGames = uiState.platformFilterHasGames, + searchQuery = uiState.platformFilterSearchQuery, focusedIndex = uiState.focusedIndex, buttonFocusIndex = uiState.platformButtonFocus, onToggle = { viewModel.togglePlatform(it) }, onToggleAll = { viewModel.toggleAllPlatforms() }, + onSortModeChange = { viewModel.setPlatformFilterSortMode(it) }, + onHasGamesChange = { viewModel.setPlatformFilterHasGames(it) }, + onSearchQueryChange = { viewModel.setPlatformFilterSearchQuery(it) }, onContinue = { viewModel.proceedFromPlatformSelect() } ) FirstRunStep.CORE_DOWNLOAD -> CoreDownloadStep( @@ -831,10 +847,15 @@ private fun UsageStatsStep( @Composable private fun PlatformSelectStep( platforms: List, + hasGames: Boolean, + searchQuery: String, focusedIndex: Int, buttonFocusIndex: Int, onToggle: (Long) -> Unit, onToggleAll: () -> Unit, + onSortModeChange: (PlatformFilterLogic.SortMode) -> Unit, + onHasGamesChange: (Boolean) -> Unit, + onSearchQueryChange: (String) -> Unit, onContinue: () -> Unit ) { val listState = rememberLazyListState() @@ -848,6 +869,13 @@ private fun PlatformSelectStep( } } + // Scroll to top when the filtered list changes (e.g. search, sort, filter) + LaunchedEffect(platforms) { + if (platforms.isNotEmpty()) { + listState.scrollToItem(0) + } + } + Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier @@ -870,6 +898,126 @@ private fun PlatformSelectStep( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Spacer(modifier = Modifier.height(Dimens.spacingSm)) + + Row( + modifier = Modifier.fillMaxWidth(0.9f), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + var showSearch by remember { mutableStateOf(searchQuery.isNotEmpty()) } + var showSortMenu by remember { mutableStateOf(false) } + + if (showSearch) { + TextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + placeholder = { Text("Search platforms...") }, + modifier = Modifier.weight(1f), + singleLine = true, + leadingIcon = { + Icon(Icons.Default.Search, "Search") + }, + trailingIcon = { + IconButton(onClick = { + onSearchQueryChange("") + showSearch = false + }) { + Icon(Icons.Default.Close, "Clear") + } + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) + } else { + Text( + text = "${platforms.size} platforms", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(Dimens.spacingXs)) { + if (!showSearch) { + IconButton(onClick = { showSearch = true }) { + Icon(Icons.Default.Search, "Search") + } + } + + Box { + IconButton(onClick = { showSortMenu = true }) { + Icon(Icons.AutoMirrored.Filled.Sort, "Sort") + } + DropdownMenu( + expanded = showSortMenu, + onDismissRequest = { showSortMenu = false } + ) { + DropdownMenuItem( + text = { Text("Default") }, + onClick = { + onSortModeChange(PlatformFilterLogic.SortMode.DEFAULT) + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Name (A-Z)") }, + onClick = { + onSortModeChange(PlatformFilterLogic.SortMode.NAME_ASC) + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Name (Z-A)") }, + onClick = { + onSortModeChange(PlatformFilterLogic.SortMode.NAME_DESC) + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Most Games") }, + onClick = { + onSortModeChange(PlatformFilterLogic.SortMode.MOST_GAMES) + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Least Games") }, + onClick = { + onSortModeChange(PlatformFilterLogic.SortMode.LEAST_GAMES) + showSortMenu = false + } + ) + } + } + + if (hasGames) { + FilterChip( + selected = true, + onClick = { onHasGamesChange(false) }, + label = { Text("Has Games") }, + leadingIcon = { + Icon( + Icons.Default.FilterList, + contentDescription = null, + modifier = Modifier.size(Dimens.iconXs) + ) + } + ) + } else { + IconButton( + onClick = { onHasGamesChange(true) } + ) { + Icon( + Icons.Default.FilterList, + "Show platforms with games" + ) + } + } + } + } Spacer(modifier = Modifier.height(Dimens.spacingMd)) LazyColumn( diff --git a/app/src/main/kotlin/com/nendo/argosy/ui/screens/firstrun/FirstRunViewModel.kt b/app/src/main/kotlin/com/nendo/argosy/ui/screens/firstrun/FirstRunViewModel.kt index 13701708..0d8dfb9a 100644 --- a/app/src/main/kotlin/com/nendo/argosy/ui/screens/firstrun/FirstRunViewModel.kt +++ b/app/src/main/kotlin/com/nendo/argosy/ui/screens/firstrun/FirstRunViewModel.kt @@ -13,6 +13,7 @@ import com.nendo.argosy.data.remote.romm.RomMResult import com.nendo.argosy.libretro.LibretroCoreManager import com.nendo.argosy.libretro.LibretroCoreRegistry import com.nendo.argosy.util.PermissionHelper +import com.nendo.argosy.util.PlatformFilterLogic import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -67,12 +68,17 @@ data class FirstRunUiState( val hasUsageStatsPermission: Boolean = false, val rommFocusField: Int? = null, val platforms: List = emptyList(), + val platformsAll: List = emptyList(), + val platformFilterSortMode: PlatformFilterLogic.SortMode = PlatformFilterLogic.SortMode.DEFAULT, + val platformFilterHasGames: Boolean = false, + val platformFilterSearchQuery: String = "", val platformButtonFocus: Int = 1, val coreDownloads: List = emptyList(), val coreDownloadComplete: Boolean = false ) @HiltViewModel +@Suppress("TooManyFunctions") class FirstRunViewModel @Inject constructor( private val application: Application, private val preferencesRepository: UserPreferencesRepository, @@ -138,11 +144,20 @@ class FirstRunViewModel @Inject constructor( viewModelScope.launch { when (val result = romMRepository.fetchAndStorePlatforms(defaultSyncEnabled = false)) { is RomMResult.Success -> { - _uiState.update { it.copy(platforms = result.data) } + val allPlatforms = result.data + val filtered = PlatformFilterLogic.filterAndSort( + items = allPlatforms, + searchQuery = _uiState.value.platformFilterSearchQuery, + hasGames = _uiState.value.platformFilterHasGames, + sortMode = _uiState.value.platformFilterSortMode, + nameSelector = { it.name }, + countSelector = { it.gameCount } + ) + _uiState.update { it.copy(platformsAll = allPlatforms, platforms = filtered) } } is RomMResult.Error -> { val platforms = platformDao.observeAllPlatforms().first() - _uiState.update { it.copy(platforms = platforms) } + _uiState.update { it.copy(platformsAll = platforms, platforms = platforms) } } } } @@ -241,23 +256,57 @@ class FirstRunViewModel @Inject constructor( fun togglePlatform(platformId: Long) { viewModelScope.launch { - val platform = _uiState.value.platforms.find { it.id == platformId } ?: return@launch + val platform = _uiState.value.platformsAll.find { it.id == platformId } ?: return@launch platformDao.updateSyncEnabled(platformId, !platform.syncEnabled) - val updatedPlatforms = platformDao.observeAllPlatforms().first() - _uiState.update { it.copy(platforms = updatedPlatforms) } + applyPlatformFilters() } } fun toggleAllPlatforms() { viewModelScope.launch { - val platforms = _uiState.value.platforms + val platforms = _uiState.value.platformsAll val allEnabled = platforms.all { it.syncEnabled } val newState = !allEnabled platforms.forEach { platform -> platformDao.updateSyncEnabled(platform.id, newState) } - val updatedPlatforms = platformDao.observeAllPlatforms().first() - _uiState.update { it.copy(platforms = updatedPlatforms) } + applyPlatformFilters() + } + } + + fun setPlatformFilterSortMode(mode: PlatformFilterLogic.SortMode) { + _uiState.update { it.copy(platformFilterSortMode = mode) } + applyPlatformFilters(resetFocus = true) + } + + fun setPlatformFilterHasGames(enabled: Boolean) { + _uiState.update { it.copy(platformFilterHasGames = enabled) } + applyPlatformFilters(resetFocus = true) + } + + fun setPlatformFilterSearchQuery(query: String) { + _uiState.update { it.copy(platformFilterSearchQuery = query) } + applyPlatformFilters(resetFocus = true) + } + + private fun applyPlatformFilters(resetFocus: Boolean = false) { + viewModelScope.launch { + val allPlatforms = platformDao.observeAllPlatforms().first() + val filtered = PlatformFilterLogic.filterAndSort( + items = allPlatforms, + searchQuery = _uiState.value.platformFilterSearchQuery, + hasGames = _uiState.value.platformFilterHasGames, + sortMode = _uiState.value.platformFilterSortMode, + nameSelector = { it.name }, + countSelector = { it.gameCount } + ) + _uiState.update { + it.copy( + platformsAll = allPlatforms, + platforms = filtered, + focusedIndex = if (resetFocus) 0 else it.focusedIndex + ) + } } } diff --git a/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/SettingsModels.kt b/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/SettingsModels.kt index c9b41476..54abbca3 100644 --- a/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/SettingsModels.kt +++ b/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/SettingsModels.kt @@ -30,6 +30,7 @@ import com.nendo.argosy.ui.input.SoundConfig import com.nendo.argosy.ui.input.SoundPreset import com.nendo.argosy.ui.input.SoundType import com.nendo.argosy.util.LogLevel +import com.nendo.argosy.util.PlatformFilterLogic import com.nendo.argosy.BuildConfig enum class SettingsSection { @@ -361,6 +362,10 @@ data class SyncSettingsState( val showPlatformFiltersModal: Boolean = false, val platformFiltersModalFocusIndex: Int = 0, val platformFiltersList: List = emptyList(), + val platformFiltersAllPlatforms: List = emptyList(), + val platformFilterSortMode: PlatformFilterLogic.SortMode = PlatformFilterLogic.SortMode.DEFAULT, + val platformFilterHasGames: Boolean = false, + val platformFilterSearchQuery: String = "", val isLoadingPlatforms: Boolean = false, val enabledPlatformCount: Int = 0, val totalGames: Int = 0, diff --git a/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/SettingsViewModel.kt index f2b4fab2..3a3e4ec4 100644 --- a/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/SettingsViewModel.kt @@ -58,6 +58,9 @@ import com.nendo.argosy.ui.screens.settings.delegates.SoundSettingsDelegate import com.nendo.argosy.ui.screens.settings.delegates.SteamSettingsDelegate import com.nendo.argosy.ui.screens.settings.delegates.StorageSettingsDelegate import com.nendo.argosy.ui.screens.settings.delegates.SyncSettingsDelegate +import com.nendo.argosy.ui.screens.settings.delegates.setPlatformFilterSortMode +import com.nendo.argosy.ui.screens.settings.delegates.setPlatformFilterHasGames +import com.nendo.argosy.ui.screens.settings.delegates.setPlatformFilterSearchQuery import com.nendo.argosy.ui.screens.settings.sections.aboutMaxFocusIndex import com.nendo.argosy.ui.screens.settings.sections.boxArtMaxFocusIndex import com.nendo.argosy.ui.screens.settings.sections.builtinControlsMaxFocusIndex @@ -2079,6 +2082,18 @@ class SettingsViewModel @Inject constructor( syncDelegate.togglePlatformSyncEnabled(viewModelScope, platformId) } + fun setPlatformFilterSortMode(mode: com.nendo.argosy.util.PlatformFilterLogic.SortMode) { + syncDelegate.setPlatformFilterSortMode(viewModelScope, mode) + } + + fun setPlatformFilterHasGames(enabled: Boolean) { + syncDelegate.setPlatformFilterHasGames(viewModelScope, enabled) + } + + fun setPlatformFilterSearchQuery(query: String) { + syncDelegate.setPlatformFilterSearchQuery(viewModelScope, query) + } + fun showRegionPicker() { syncDelegate.showRegionPicker() soundManager.play(SoundType.OPEN_MODAL) diff --git a/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/components/PlatformFiltersModal.kt b/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/components/PlatformFiltersModal.kt index 4647b59d..c662cbbd 100644 --- a/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/components/PlatformFiltersModal.kt +++ b/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/components/PlatformFiltersModal.kt @@ -6,8 +6,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource 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.size @@ -16,11 +18,27 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FilterList import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -32,13 +50,19 @@ import com.nendo.argosy.ui.components.SwitchPreference import com.nendo.argosy.ui.screens.settings.PlatformFilterItem import com.nendo.argosy.ui.theme.Dimens import com.nendo.argosy.ui.theme.LocalLauncherTheme +import com.nendo.argosy.util.PlatformFilterLogic @Composable fun PlatformFiltersModal( platforms: List, + hasGames: Boolean, + searchQuery: String, focusIndex: Int, isLoading: Boolean, onTogglePlatform: (Long) -> Unit, + onSortModeChange: (PlatformFilterLogic.SortMode) -> Unit, + onHasGamesChange: (Boolean) -> Unit, + onSearchQueryChange: (String) -> Unit, onDismiss: () -> Unit ) { val listState = rememberLazyListState() @@ -50,6 +74,13 @@ fun PlatformFiltersModal( focusedIndex = focusIndex ) + // Scroll to top when the filtered list changes (e.g. search, sort, filter) + LaunchedEffect(platforms) { + if (platforms.isNotEmpty()) { + listState.scrollToItem(0) + } + } + Box( modifier = Modifier .fillMaxSize() @@ -83,6 +114,127 @@ fun PlatformFiltersModal( Spacer(modifier = Modifier.height(Dimens.spacingSm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + var showSearch by remember { mutableStateOf(searchQuery.isNotEmpty()) } + var showSortMenu by remember { mutableStateOf(false) } + + if (showSearch) { + TextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + placeholder = { Text("Search platforms...") }, + modifier = Modifier.weight(1f), + singleLine = true, + leadingIcon = { + Icon(Icons.Default.Search, "Search") + }, + trailingIcon = { + IconButton(onClick = { + onSearchQueryChange("") + showSearch = false + }) { + Icon(Icons.Default.Close, "Clear") + } + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) + } else { + Text( + text = "${platforms.size} platforms", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(Dimens.spacingXs)) { + if (!showSearch) { + IconButton(onClick = { showSearch = true }) { + Icon(Icons.Default.Search, "Search") + } + } + + Box { + IconButton(onClick = { showSortMenu = true }) { + Icon(Icons.AutoMirrored.Filled.Sort, "Sort") + } + DropdownMenu( + expanded = showSortMenu, + onDismissRequest = { showSortMenu = false } + ) { + DropdownMenuItem( + text = { Text("Default") }, + onClick = { + onSortModeChange(PlatformFilterLogic.SortMode.DEFAULT) + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Name (A-Z)") }, + onClick = { + onSortModeChange(PlatformFilterLogic.SortMode.NAME_ASC) + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Name (Z-A)") }, + onClick = { + onSortModeChange(PlatformFilterLogic.SortMode.NAME_DESC) + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Most Games") }, + onClick = { + onSortModeChange(PlatformFilterLogic.SortMode.MOST_GAMES) + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text("Least Games") }, + onClick = { + onSortModeChange(PlatformFilterLogic.SortMode.LEAST_GAMES) + showSortMenu = false + } + ) + } + } + + if (hasGames) { + FilterChip( + selected = true, + onClick = { onHasGamesChange(false) }, + label = { Text("Has Games") }, + leadingIcon = { + Icon( + Icons.Default.FilterList, + contentDescription = null, + modifier = Modifier.size(Dimens.iconXs) + ) + } + ) + } else { + IconButton( + onClick = { onHasGamesChange(true) } + ) { + Icon( + Icons.Default.FilterList, + "Show platforms with games" + ) + } + } + } + } + + Spacer(modifier = Modifier.height(Dimens.spacingSm)) + if (isLoading) { Box( modifier = Modifier diff --git a/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/delegates/SyncSettingsDelegate.kt b/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/delegates/SyncSettingsDelegate.kt index 95a66f96..56bfd2c5 100644 --- a/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/delegates/SyncSettingsDelegate.kt +++ b/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/delegates/SyncSettingsDelegate.kt @@ -16,6 +16,7 @@ import com.nendo.argosy.ui.notification.NotificationManager import com.nendo.argosy.ui.notification.showError import com.nendo.argosy.ui.screens.settings.PlatformFilterItem import com.nendo.argosy.ui.screens.settings.SyncSettingsState +import com.nendo.argosy.util.PlatformFilterLogic import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -386,7 +387,7 @@ class SyncSettingsDelegate @Inject constructor( notificationManager.showError("Failed to fetch platforms: ${result.exceptionOrNull()?.message}") } - val platforms = platformDao.getAllPlatformsOrdered().map { entity -> + val allPlatforms = platformDao.getAllPlatformsOrdered().map { entity -> PlatformFilterItem( id = entity.id, name = entity.name, @@ -395,14 +396,24 @@ class SyncSettingsDelegate @Inject constructor( syncEnabled = entity.syncEnabled ) } - val enabledCount = platforms.count { it.syncEnabled } + val currentState = _state.value + val filtered = PlatformFilterLogic.filterAndSort( + items = allPlatforms, + searchQuery = currentState.platformFilterSearchQuery, + hasGames = currentState.platformFilterHasGames, + sortMode = currentState.platformFilterSortMode, + nameSelector = { it.name }, + countSelector = { it.romCount } + ) + val enabledCount = allPlatforms.count { it.syncEnabled } _state.update { it.copy( - platformFiltersList = platforms, + platformFiltersAllPlatforms = allPlatforms, + platformFiltersList = filtered, isLoadingPlatforms = false, platformFiltersModalFocusIndex = 0, enabledPlatformCount = enabledCount, - totalPlatforms = platforms.size + totalPlatforms = allPlatforms.size ) } } diff --git a/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/delegates/SyncSettingsDelegateExt.kt b/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/delegates/SyncSettingsDelegateExt.kt new file mode 100644 index 00000000..7ce4ccc3 --- /dev/null +++ b/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/delegates/SyncSettingsDelegateExt.kt @@ -0,0 +1,65 @@ +package com.nendo.argosy.ui.screens.settings.delegates + +import com.nendo.argosy.util.PlatformFilterLogic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +fun SyncSettingsDelegate.setPlatformFilterSortMode(scope: CoroutineScope, mode: PlatformFilterLogic.SortMode) { + scope.launch { + val currentState = state.value + val filtered = PlatformFilterLogic.filterAndSort( + items = currentState.platformFiltersAllPlatforms, + searchQuery = currentState.platformFilterSearchQuery, + hasGames = currentState.platformFilterHasGames, + sortMode = mode, + nameSelector = { it.name }, + countSelector = { it.romCount } + ) + + updateState(currentState.copy( + platformFilterSortMode = mode, + platformFiltersList = filtered, + platformFiltersModalFocusIndex = 0 + )) + } +} + +fun SyncSettingsDelegate.setPlatformFilterHasGames(scope: CoroutineScope, enabled: Boolean) { + scope.launch { + val currentState = state.value + val filtered = PlatformFilterLogic.filterAndSort( + items = currentState.platformFiltersAllPlatforms, + searchQuery = currentState.platformFilterSearchQuery, + hasGames = enabled, + sortMode = currentState.platformFilterSortMode, + nameSelector = { it.name }, + countSelector = { it.romCount } + ) + + updateState(currentState.copy( + platformFilterHasGames = enabled, + platformFiltersList = filtered, + platformFiltersModalFocusIndex = 0 + )) + } +} + +fun SyncSettingsDelegate.setPlatformFilterSearchQuery(scope: CoroutineScope, query: String) { + scope.launch { + val currentState = state.value + val filtered = PlatformFilterLogic.filterAndSort( + items = currentState.platformFiltersAllPlatforms, + searchQuery = query, + hasGames = currentState.platformFilterHasGames, + sortMode = currentState.platformFilterSortMode, + nameSelector = { it.name }, + countSelector = { it.romCount } + ) + + updateState(currentState.copy( + platformFilterSearchQuery = query, + platformFiltersList = filtered, + platformFiltersModalFocusIndex = 0 + )) + } +} diff --git a/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/sections/SyncSettingsSection.kt b/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/sections/SyncSettingsSection.kt index f4aca1f3..b05e2532 100644 --- a/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/sections/SyncSettingsSection.kt +++ b/app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/sections/SyncSettingsSection.kt @@ -133,9 +133,14 @@ fun SyncSettingsSection( if (uiState.syncSettings.showPlatformFiltersModal) { PlatformFiltersModal( platforms = uiState.syncSettings.platformFiltersList, + hasGames = uiState.syncSettings.platformFilterHasGames, + searchQuery = uiState.syncSettings.platformFilterSearchQuery, focusIndex = uiState.syncSettings.platformFiltersModalFocusIndex, isLoading = uiState.syncSettings.isLoadingPlatforms, onTogglePlatform = { viewModel.togglePlatformSyncEnabled(it) }, + onSortModeChange = { viewModel.setPlatformFilterSortMode(it) }, + onHasGamesChange = { viewModel.setPlatformFilterHasGames(it) }, + onSearchQueryChange = { viewModel.setPlatformFilterSearchQuery(it) }, onDismiss = { viewModel.dismissPlatformFiltersModal() } ) } diff --git a/app/src/main/kotlin/com/nendo/argosy/util/PlatformFilterLogic.kt b/app/src/main/kotlin/com/nendo/argosy/util/PlatformFilterLogic.kt new file mode 100644 index 00000000..2608da6e --- /dev/null +++ b/app/src/main/kotlin/com/nendo/argosy/util/PlatformFilterLogic.kt @@ -0,0 +1,50 @@ +package com.nendo.argosy.util + +object PlatformFilterLogic { + enum class SortMode { + DEFAULT, + NAME_ASC, + NAME_DESC, + MOST_GAMES, + LEAST_GAMES + } + + fun filterAndSort( + items: List, + searchQuery: String, + hasGames: Boolean, + sortMode: SortMode, + nameSelector: (T) -> String, + countSelector: (T) -> Int, + defaultSortSelector: ((T) -> Comparable<*>?)? = null + ): List { + val query = searchQuery.trim() + return items.filter { item -> + if (hasGames && countSelector(item) <= 0) return@filter false + if (query.isNotEmpty() && !nameSelector(item).contains(query, ignoreCase = true)) return@filter false + true + }.sortedWith { a, b -> + when (sortMode) { + SortMode.NAME_ASC -> String.CASE_INSENSITIVE_ORDER.compare(nameSelector(a), nameSelector(b)) + SortMode.NAME_DESC -> String.CASE_INSENSITIVE_ORDER.compare(nameSelector(b), nameSelector(a)) + SortMode.MOST_GAMES -> { + val countCompare = countSelector(b).compareTo(countSelector(a)) + if (countCompare != 0) countCompare + else String.CASE_INSENSITIVE_ORDER.compare(nameSelector(a), nameSelector(b)) + } + SortMode.LEAST_GAMES -> { + val countCompare = countSelector(a).compareTo(countSelector(b)) + if (countCompare != 0) countCompare + else String.CASE_INSENSITIVE_ORDER.compare(nameSelector(a), nameSelector(b)) + } + SortMode.DEFAULT -> { + if (defaultSortSelector != null) { + compareValues(defaultSortSelector(a), defaultSortSelector(b)) + } else { + 0 + } + } + } + } + } +} diff --git a/app/src/test/kotlin/com/nendo/argosy/util/PlatformFilterLogicTest.kt b/app/src/test/kotlin/com/nendo/argosy/util/PlatformFilterLogicTest.kt new file mode 100644 index 00000000..6789bd0d --- /dev/null +++ b/app/src/test/kotlin/com/nendo/argosy/util/PlatformFilterLogicTest.kt @@ -0,0 +1,277 @@ +package com.nendo.argosy.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlatformFilterLogicTest { + + data class TestPlatform( + val name: String, + val gameCount: Int, + val sortOrder: Int = 0 + ) + + private val testPlatforms = listOf( + TestPlatform("PlayStation 5", 150, 1), + TestPlatform("PlayStation 2", 500, 2), + TestPlatform("PlayStation 10", 50, 3), + TestPlatform("Xbox Series X", 120, 4), + TestPlatform("Nintendo Switch", 300, 5), + TestPlatform("Game Boy Advance", 200, 6), + TestPlatform("Sega Genesis", 0, 7), + TestPlatform("Atari 2600", 0, 8) + ) + + @Test + fun `filterAndSort with DEFAULT sort mode preserves original order`() { + val result = PlatformFilterLogic.filterAndSort( + items = testPlatforms, + searchQuery = "", + hasGames = false, + sortMode = PlatformFilterLogic.SortMode.DEFAULT, + nameSelector = { it.name }, + countSelector = { it.gameCount }, + defaultSortSelector = { it.sortOrder } + ) + + assertEquals(testPlatforms, result) + } + + @Test + fun `filterAndSort with NAME_ASC sorts alphabetically case-insensitive`() { + val result = PlatformFilterLogic.filterAndSort( + items = testPlatforms, + searchQuery = "", + hasGames = false, + sortMode = PlatformFilterLogic.SortMode.NAME_ASC, + nameSelector = { it.name }, + countSelector = { it.gameCount } + ) + + val expected = listOf("Atari 2600", "Game Boy Advance", "Nintendo Switch", + "PlayStation 10", "PlayStation 2", "PlayStation 5", "Sega Genesis", "Xbox Series X") + assertEquals(expected, result.map { it.name }) + } + + @Test + fun `filterAndSort with NAME_DESC sorts reverse alphabetically`() { + val result = PlatformFilterLogic.filterAndSort( + items = testPlatforms, + searchQuery = "", + hasGames = false, + sortMode = PlatformFilterLogic.SortMode.NAME_DESC, + nameSelector = { it.name }, + countSelector = { it.gameCount } + ) + + val expected = listOf("Xbox Series X", "Sega Genesis", "PlayStation 5", + "PlayStation 2", "PlayStation 10", "Nintendo Switch", "Game Boy Advance", "Atari 2600") + assertEquals(expected, result.map { it.name }) + } + + @Test + fun `filterAndSort with MOST_GAMES sorts by game count descending`() { + val result = PlatformFilterLogic.filterAndSort( + items = testPlatforms, + searchQuery = "", + hasGames = false, + sortMode = PlatformFilterLogic.SortMode.MOST_GAMES, + nameSelector = { it.name }, + countSelector = { it.gameCount } + ) + + assertEquals(listOf(500, 300, 200, 150, 120, 50, 0, 0), result.map { it.gameCount }) + } + + @Test + fun `filterAndSort with LEAST_GAMES sorts by game count ascending`() { + val result = PlatformFilterLogic.filterAndSort( + items = testPlatforms, + searchQuery = "", + hasGames = false, + sortMode = PlatformFilterLogic.SortMode.LEAST_GAMES, + nameSelector = { it.name }, + countSelector = { it.gameCount } + ) + + assertEquals(listOf(0, 0, 50, 120, 150, 200, 300, 500), result.map { it.gameCount }) + } + + @Test + fun `filterAndSort filters by search query case-insensitive`() { + val result = PlatformFilterLogic.filterAndSort( + items = testPlatforms, + searchQuery = "playstation", + hasGames = false, + sortMode = PlatformFilterLogic.SortMode.DEFAULT, + nameSelector = { it.name }, + countSelector = { it.gameCount }, + defaultSortSelector = { it.sortOrder } + ) + + assertEquals(3, result.size) + assertEquals(listOf("PlayStation 5", "PlayStation 2", "PlayStation 10"), result.map { it.name }) + } + + @Test + fun `filterAndSort filters by partial search query`() { + val result = PlatformFilterLogic.filterAndSort( + items = testPlatforms, + searchQuery = "boy", + hasGames = false, + sortMode = PlatformFilterLogic.SortMode.DEFAULT, + nameSelector = { it.name }, + countSelector = { it.gameCount }, + defaultSortSelector = { it.sortOrder } + ) + + assertEquals(1, result.size) + assertEquals("Game Boy Advance", result[0].name) + } + + @Test + fun `filterAndSort with hasGames filters out platforms with zero games`() { + val result = PlatformFilterLogic.filterAndSort( + items = testPlatforms, + searchQuery = "", + hasGames = true, + sortMode = PlatformFilterLogic.SortMode.DEFAULT, + nameSelector = { it.name }, + countSelector = { it.gameCount }, + defaultSortSelector = { it.sortOrder } + ) + + assertEquals(6, result.size) + result.forEach { platform -> + assert(platform.gameCount > 0) { "${platform.name} should have games" } + } + } + + @Test + fun `filterAndSort combines search and hasGames filters`() { + val result = PlatformFilterLogic.filterAndSort( + items = testPlatforms, + searchQuery = "play", + hasGames = true, + sortMode = PlatformFilterLogic.SortMode.DEFAULT, + nameSelector = { it.name }, + countSelector = { it.gameCount }, + defaultSortSelector = { it.sortOrder } + ) + + assertEquals(3, result.size) + assertEquals(listOf("PlayStation 5", "PlayStation 2", "PlayStation 10"), result.map { it.name }) + } + + @Test + fun `filterAndSort combines all filters with sorting`() { + val result = PlatformFilterLogic.filterAndSort( + items = testPlatforms, + searchQuery = "play", + hasGames = true, + sortMode = PlatformFilterLogic.SortMode.MOST_GAMES, + nameSelector = { it.name }, + countSelector = { it.gameCount } + ) + + assertEquals(3, result.size) + assertEquals(listOf("PlayStation 2", "PlayStation 5", "PlayStation 10"), result.map { it.name }) + assertEquals(listOf(500, 150, 50), result.map { it.gameCount }) + } + + @Test + fun `filterAndSort handles empty search query`() { + val result = PlatformFilterLogic.filterAndSort( + items = testPlatforms, + searchQuery = " ", + hasGames = false, + sortMode = PlatformFilterLogic.SortMode.DEFAULT, + nameSelector = { it.name }, + countSelector = { it.gameCount }, + defaultSortSelector = { it.sortOrder } + ) + + assertEquals(testPlatforms.size, result.size) + } + + @Test + fun `filterAndSort returns empty list when no matches`() { + val result = PlatformFilterLogic.filterAndSort( + items = testPlatforms, + searchQuery = "nonexistent", + hasGames = false, + sortMode = PlatformFilterLogic.SortMode.DEFAULT, + nameSelector = { it.name }, + countSelector = { it.gameCount } + ) + + assertEquals(0, result.size) + } + + @Test + fun `filterAndSort with numeric edge cases (1, 2, 10) sorts correctly`() { + val platforms = listOf( + TestPlatform("A", 1), + TestPlatform("B", 10), + TestPlatform("C", 2) + ) + + val mostGames = PlatformFilterLogic.filterAndSort( + items = platforms, + searchQuery = "", + hasGames = false, + sortMode = PlatformFilterLogic.SortMode.MOST_GAMES, + nameSelector = { it.name }, + countSelector = { it.gameCount } + ) + // Should be 10 (B), 2 (C), 1 (A) + assertEquals(listOf(10, 2, 1), mostGames.map { it.gameCount }) + assertEquals(listOf("B", "C", "A"), mostGames.map { it.name }) + + val leastGames = PlatformFilterLogic.filterAndSort( + items = platforms, + searchQuery = "", + hasGames = false, + sortMode = PlatformFilterLogic.SortMode.LEAST_GAMES, + nameSelector = { it.name }, + countSelector = { it.gameCount } + ) + // Should be 1 (A), 2 (C), 10 (B) + assertEquals(listOf(1, 2, 10), leastGames.map { it.gameCount }) + assertEquals(listOf("A", "C", "B"), leastGames.map { it.name }) + } + + @Test + fun `filterAndSort should use name as secondary sort for same game counts`() { + val platforms = listOf( + TestPlatform("B", 10), + TestPlatform("A", 10), + TestPlatform("C", 5) + ) + + val result = PlatformFilterLogic.filterAndSort( + items = platforms, + searchQuery = "", + hasGames = false, + sortMode = PlatformFilterLogic.SortMode.MOST_GAMES, + nameSelector = { it.name }, + countSelector = { it.gameCount } + ) + + assertEquals(listOf("A", "B", "C"), result.map { it.name }) + } + + @Test + fun `filterAndSort handles empty input list`() { + val result = PlatformFilterLogic.filterAndSort( + items = emptyList(), + searchQuery = "test", + hasGames = true, + sortMode = PlatformFilterLogic.SortMode.NAME_ASC, + nameSelector = { it.name }, + countSelector = { it.gameCount } + ) + + assertEquals(0, result.size) + } +}