feature: support searching on Model Selection screen
This commit is contained in:
parent
2b3ba770dd
commit
77edad5a01
|
|
@ -118,16 +118,60 @@ fun AppContent(
|
||||||
// Create scaffold's top & bottom bar configs based on current route
|
// Create scaffold's top & bottom bar configs based on current route
|
||||||
val scaffoldConfig = when {
|
val scaffoldConfig = when {
|
||||||
// Model selection screen
|
// Model selection screen
|
||||||
currentRoute == AppDestinations.MODEL_SELECTION_ROUTE ->
|
currentRoute == AppDestinations.MODEL_SELECTION_ROUTE -> {
|
||||||
|
// Collect states for bottom bar
|
||||||
|
val isSearchActive by modelSelectionViewModel.isSearchActive.collectAsState()
|
||||||
|
val sortOrder by modelSelectionViewModel.sortOrder.collectAsState()
|
||||||
|
val showSortMenu by modelSelectionViewModel.showSortMenu.collectAsState()
|
||||||
|
val activeFilters by modelSelectionViewModel.activeFilters.collectAsState()
|
||||||
|
val showFilterMenu by modelSelectionViewModel.showFilterMenu.collectAsState()
|
||||||
|
val preselectedModel by modelSelectionViewModel.preselectedModel.collectAsState()
|
||||||
|
|
||||||
ScaffoldConfig(
|
ScaffoldConfig(
|
||||||
topBarConfig = TopBarConfig.Default(
|
topBarConfig =
|
||||||
title = "Models",
|
if (isSearchActive) TopBarConfig.None()
|
||||||
|
else TopBarConfig.Default(
|
||||||
|
title = "Select a Model",
|
||||||
navigationIcon = NavigationIcon.Menu {
|
navigationIcon = NavigationIcon.Menu {
|
||||||
modelSelectionViewModel.resetSelection()
|
modelSelectionViewModel.resetSelection()
|
||||||
openDrawer()
|
openDrawer()
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
bottomBarConfig = BottomBarConfig.ModelSelection(
|
||||||
|
search = BottomBarConfig.ModelSelection.SearchConfig(
|
||||||
|
isActive = isSearchActive,
|
||||||
|
onToggleSearch = modelSelectionViewModel::toggleSearchState,
|
||||||
|
textFieldState = modelSelectionViewModel.searchFieldState,
|
||||||
|
onSearch = { /* No-op for now */ }
|
||||||
|
),
|
||||||
|
sorting = BottomBarConfig.ModelSelection.SortingConfig(
|
||||||
|
currentOrder = sortOrder,
|
||||||
|
isMenuVisible = showSortMenu,
|
||||||
|
toggleMenu = modelSelectionViewModel::toggleSortMenu,
|
||||||
|
selectOrder = {
|
||||||
|
modelSelectionViewModel.setSortOrder(it)
|
||||||
|
modelSelectionViewModel.toggleSortMenu(false)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
filtering = BottomBarConfig.ModelSelection.FilteringConfig(
|
||||||
|
isActive = activeFilters.any { it.value },
|
||||||
|
filters = activeFilters,
|
||||||
|
onToggleFilter = modelSelectionViewModel::toggleFilter,
|
||||||
|
onClearFilters = modelSelectionViewModel::clearFilters,
|
||||||
|
isMenuVisible = showFilterMenu,
|
||||||
|
toggleMenu = modelSelectionViewModel::toggleFilterMenu
|
||||||
|
),
|
||||||
|
runAction = BottomBarConfig.ModelSelection.RunActionConfig(
|
||||||
|
selectedModel = preselectedModel,
|
||||||
|
onRun = { model ->
|
||||||
|
modelSelectionViewModel.confirmSelectedModel(model)
|
||||||
|
navigationActions.navigateToModelLoading()
|
||||||
|
modelSelectionViewModel.toggleSearchState(false)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Model loading screen
|
// Model loading screen
|
||||||
currentRoute == AppDestinations.MODEL_LOADING_ROUTE ->
|
currentRoute == AppDestinations.MODEL_LOADING_ROUTE ->
|
||||||
|
|
@ -297,9 +341,6 @@ fun AppContent(
|
||||||
// Model Selection Screen
|
// Model Selection Screen
|
||||||
composable(AppDestinations.MODEL_SELECTION_ROUTE) {
|
composable(AppDestinations.MODEL_SELECTION_ROUTE) {
|
||||||
ModelSelectionScreen(
|
ModelSelectionScreen(
|
||||||
onModelConfirmed = { modelInfo ->
|
|
||||||
navigationActions.navigateToModelLoading()
|
|
||||||
},
|
|
||||||
onManageModelsClicked = {
|
onManageModelsClicked = {
|
||||||
navigationActions.navigateToModelsManagement()
|
navigationActions.navigateToModelsManagement()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ fun AppScaffold(
|
||||||
) {
|
) {
|
||||||
val topBar: @Composable () -> Unit = {
|
val topBar: @Composable () -> Unit = {
|
||||||
when (val topConfig = topBarconfig) {
|
when (val topConfig = topBarconfig) {
|
||||||
|
is TopBarConfig.None -> {}
|
||||||
|
|
||||||
is TopBarConfig.Default -> DefaultTopBar(
|
is TopBarConfig.Default -> DefaultTopBar(
|
||||||
title = topBarconfig.title,
|
title = topBarconfig.title,
|
||||||
onNavigateBack = topConfig.navigationIcon.backAction,
|
onNavigateBack = topConfig.navigationIcon.backAction,
|
||||||
|
|
@ -66,6 +68,15 @@ fun AppScaffold(
|
||||||
when (val config = bottomBarConfig) {
|
when (val config = bottomBarConfig) {
|
||||||
is BottomBarConfig.None -> { /* No bottom bar */ }
|
is BottomBarConfig.None -> { /* No bottom bar */ }
|
||||||
|
|
||||||
|
is BottomBarConfig.ModelSelection -> {
|
||||||
|
ModelSelectionBottomBar(
|
||||||
|
search = config.search,
|
||||||
|
sorting = config.sorting,
|
||||||
|
filtering = config.filtering,
|
||||||
|
runAction = config.runAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
is BottomBarConfig.ModelsManagement -> {
|
is BottomBarConfig.ModelsManagement -> {
|
||||||
ModelsManagementBottomBar(
|
ModelsManagementBottomBar(
|
||||||
sorting = config.sorting,
|
sorting = config.sorting,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
package com.example.llama.revamp.ui.components
|
package com.example.llama.revamp.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.foundation.text.input.clearText
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Sort
|
import androidx.compose.material.icons.automirrored.filled.Sort
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.Backspace
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.ClearAll
|
import androidx.compose.material.icons.filled.ClearAll
|
||||||
|
|
@ -11,11 +15,17 @@ import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.DeleteSweep
|
import androidx.compose.material.icons.filled.DeleteSweep
|
||||||
import androidx.compose.material.icons.filled.FilterAlt
|
import androidx.compose.material.icons.filled.FilterAlt
|
||||||
import androidx.compose.material.icons.filled.FolderOpen
|
import androidx.compose.material.icons.filled.FolderOpen
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.SearchOff
|
||||||
import androidx.compose.material.icons.filled.SelectAll
|
import androidx.compose.material.icons.filled.SelectAll
|
||||||
|
import androidx.compose.material.icons.outlined.FilterAlt
|
||||||
import androidx.compose.material3.BottomAppBar
|
import androidx.compose.material3.BottomAppBar
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -37,6 +47,41 @@ sealed class BottomBarConfig {
|
||||||
|
|
||||||
object None : BottomBarConfig()
|
object None : BottomBarConfig()
|
||||||
|
|
||||||
|
data class ModelSelection(
|
||||||
|
val search: SearchConfig,
|
||||||
|
val sorting: SortingConfig,
|
||||||
|
val filtering: FilteringConfig,
|
||||||
|
val runAction: RunActionConfig
|
||||||
|
) : BottomBarConfig() {
|
||||||
|
data class SearchConfig(
|
||||||
|
val isActive: Boolean,
|
||||||
|
val onToggleSearch: (Boolean) -> Unit,
|
||||||
|
val textFieldState: TextFieldState,
|
||||||
|
val onSearch: (String) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SortingConfig(
|
||||||
|
val currentOrder: ModelSortOrder,
|
||||||
|
val isMenuVisible: Boolean,
|
||||||
|
val toggleMenu: (Boolean) -> Unit,
|
||||||
|
val selectOrder: (ModelSortOrder) -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
data class FilteringConfig(
|
||||||
|
val isActive: Boolean,
|
||||||
|
val filters: Map<String, Boolean>, // Filter name -> enabled
|
||||||
|
val onToggleFilter: (String, Boolean) -> Unit,
|
||||||
|
val onClearFilters: () -> Unit,
|
||||||
|
val isMenuVisible: Boolean,
|
||||||
|
val toggleMenu: (Boolean) -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RunActionConfig(
|
||||||
|
val selectedModel: ModelInfo?,
|
||||||
|
val onRun: (ModelInfo) -> Unit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
data class ModelsManagement(
|
data class ModelsManagement(
|
||||||
val sorting: SortingConfig,
|
val sorting: SortingConfig,
|
||||||
val filtering: FilteringConfig,
|
val filtering: FilteringConfig,
|
||||||
|
|
@ -70,7 +115,140 @@ sealed class BottomBarConfig {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO-han.yin: add more bottom bar types here
|
// TODO-han.yin: add bottom bar config for Conversation Screen!
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ModelSelectionBottomBar(
|
||||||
|
search: BottomBarConfig.ModelSelection.SearchConfig,
|
||||||
|
sorting: BottomBarConfig.ModelSelection.SortingConfig,
|
||||||
|
filtering: BottomBarConfig.ModelSelection.FilteringConfig,
|
||||||
|
runAction: BottomBarConfig.ModelSelection.RunActionConfig
|
||||||
|
) {
|
||||||
|
BottomAppBar(
|
||||||
|
actions = {
|
||||||
|
if (search.isActive) {
|
||||||
|
// Quit search action
|
||||||
|
IconButton(onClick = { search.onToggleSearch(false) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SearchOff,
|
||||||
|
contentDescription = "Quit search mode"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear query action
|
||||||
|
IconButton(onClick = { search.textFieldState.clearText() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Outlined.Backspace,
|
||||||
|
contentDescription = "Clear query text"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Enter search action
|
||||||
|
IconButton(onClick = { search.onToggleSearch(true) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = "Search models"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorting action
|
||||||
|
IconButton(onClick = { sorting.toggleMenu(true) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.Sort,
|
||||||
|
contentDescription = "Sort models"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorting dropdown menu
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = sorting.isMenuVisible,
|
||||||
|
onDismissRequest = { sorting.toggleMenu(false) }
|
||||||
|
) {
|
||||||
|
val sortOptions = listOf(
|
||||||
|
Triple(ModelSortOrder.NAME_ASC, "Name (A-Z)", "Sort by name in ascending order"),
|
||||||
|
Triple(ModelSortOrder.NAME_DESC, "Name (Z-A)", "Sort by name in descending order"),
|
||||||
|
Triple(ModelSortOrder.SIZE_ASC, "Size (Smallest first)", "Sort by size in ascending order"),
|
||||||
|
Triple(ModelSortOrder.SIZE_DESC, "Size (Largest first)", "Sort by size in descending order"),
|
||||||
|
Triple(ModelSortOrder.LAST_USED, "Last used", "Sort by last used")
|
||||||
|
)
|
||||||
|
|
||||||
|
sortOptions.forEach { (order, label, description) ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(label) },
|
||||||
|
trailingIcon = {
|
||||||
|
if (sorting.currentOrder == order)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = "$description, selected"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { sorting.selectOrder(order) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter action
|
||||||
|
IconButton(onClick = { filtering.toggleMenu(true) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (filtering.isActive) Icons.Default.FilterAlt
|
||||||
|
else Icons.Outlined.FilterAlt,
|
||||||
|
contentDescription = "Filter models"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter dropdown menu
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = filtering.isMenuVisible,
|
||||||
|
onDismissRequest = { filtering.toggleMenu(false) }
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Filter by",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
filtering.filters.forEach { (filter, isEnabled) ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(filter) },
|
||||||
|
leadingIcon = {
|
||||||
|
Checkbox(
|
||||||
|
checked = isEnabled,
|
||||||
|
onCheckedChange = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { filtering.onToggleFilter(filter, !isEnabled) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Clear filters") },
|
||||||
|
onClick = {
|
||||||
|
filtering.onClearFilters()
|
||||||
|
filtering.toggleMenu(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
// Only show FAB if a model is selected
|
||||||
|
runAction.selectedModel?.let { model ->
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { runAction.onRun(model) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PlayArrow,
|
||||||
|
contentDescription = "Run with selected model"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.WarningAmber
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Label
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
|
@ -36,6 +35,12 @@ sealed class TopBarConfig {
|
||||||
abstract val title: String
|
abstract val title: String
|
||||||
abstract val navigationIcon: NavigationIcon
|
abstract val navigationIcon: NavigationIcon
|
||||||
|
|
||||||
|
// No top bar at all
|
||||||
|
data class None(
|
||||||
|
override val title: String = "",
|
||||||
|
override val navigationIcon: NavigationIcon = NavigationIcon.None
|
||||||
|
) : TopBarConfig()
|
||||||
|
|
||||||
// Default/simple top bar with only a navigation icon
|
// Default/simple top bar with only a navigation icon
|
||||||
data class Default(
|
data class Default(
|
||||||
override val title: String,
|
override val title: String,
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,174 @@
|
||||||
package com.example.llama.revamp.ui.screens
|
package com.example.llama.revamp.ui.screens
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.input.clearText
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.FolderOpen
|
import androidx.compose.material.icons.filled.FolderOpen
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.SearchOff
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DockedSearchBar
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SearchBarDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.llama.revamp.data.model.ModelInfo
|
|
||||||
import com.example.llama.revamp.ui.components.ModelCardFullExpandable
|
import com.example.llama.revamp.ui.components.ModelCardFullExpandable
|
||||||
import com.example.llama.revamp.viewmodel.ModelSelectionViewModel
|
import com.example.llama.revamp.viewmodel.ModelSelectionViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ModelSelectionScreen(
|
fun ModelSelectionScreen(
|
||||||
onModelConfirmed: (ModelInfo) -> Unit,
|
|
||||||
onManageModelsClicked: () -> Unit,
|
onManageModelsClicked: () -> Unit,
|
||||||
viewModel: ModelSelectionViewModel,
|
viewModel: ModelSelectionViewModel,
|
||||||
) {
|
) {
|
||||||
val models by viewModel.availableModels.collectAsState()
|
val filteredModels by viewModel.filteredModels.collectAsState()
|
||||||
val preselectedModel by viewModel.preselectedModel.collectAsState()
|
val preselectedModel by viewModel.preselectedModel.collectAsState()
|
||||||
|
|
||||||
// Handle back button press
|
val textFieldState = viewModel.searchFieldState
|
||||||
BackHandler(preselectedModel != null) {
|
val isSearchActive by viewModel.isSearchActive.collectAsState()
|
||||||
viewModel.onBackPressed()
|
val searchQuery by remember(textFieldState) {
|
||||||
|
derivedStateOf { textFieldState.text.toString() }
|
||||||
|
}
|
||||||
|
val queryResults by viewModel.queryResults.collectAsState()
|
||||||
|
|
||||||
|
val activeFilters by viewModel.activeFilters.collectAsState()
|
||||||
|
val activeFiltersCount by remember(activeFilters) {
|
||||||
|
derivedStateOf { activeFilters.count { it.value } }
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
Column(
|
val focusRequester = remember { FocusRequester() }
|
||||||
modifier = Modifier
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = 16.dp)
|
val toggleSearchFocusAndIme: (Boolean) -> Unit = { show ->
|
||||||
|
if (show) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
keyboardController?.show()
|
||||||
|
} else {
|
||||||
|
focusRequester.freeFocus()
|
||||||
|
keyboardController?.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle back button press
|
||||||
|
BackHandler(preselectedModel != null || isSearchActive) {
|
||||||
|
if (isSearchActive) {
|
||||||
|
viewModel.toggleSearchState(false)
|
||||||
|
} else {
|
||||||
|
viewModel.onBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect (isSearchActive) {
|
||||||
|
if (isSearchActive) {
|
||||||
|
toggleSearchFocusAndIme(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
if (models.isEmpty()) {
|
if (isSearchActive) {
|
||||||
EmptyModelsView(onManageModelsClicked)
|
DockedSearchBar(
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
|
inputField = {
|
||||||
|
SearchBarDefaults.InputField(
|
||||||
|
modifier = Modifier.focusRequester(focusRequester),
|
||||||
|
query = textFieldState.text.toString(),
|
||||||
|
onQueryChange = { textFieldState.edit { replace(0, length, it) } },
|
||||||
|
onSearch = {},
|
||||||
|
expanded = true,
|
||||||
|
onExpandedChange = { expanded ->
|
||||||
|
viewModel.toggleSearchState(expanded)
|
||||||
|
textFieldState.clearText()
|
||||||
|
},
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
trailingIcon = { Icon(Icons.Default.MoreVert, contentDescription = null) },
|
||||||
|
placeholder = { Text("Type to search your models") }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
expanded = true,
|
||||||
|
onExpandedChange = {
|
||||||
|
viewModel.toggleSearchState(it)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (queryResults.isEmpty()) {
|
||||||
|
if (searchQuery.isNotBlank()) {
|
||||||
|
// Show "no results" message
|
||||||
|
EmptySearchResultsView(
|
||||||
|
onClearSearch = {
|
||||||
|
textFieldState.clearText()
|
||||||
|
toggleSearchFocusAndIme(true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
items(items = models, key = { it.id }) { model ->
|
items(items = queryResults, key = { it.id }) { model ->
|
||||||
|
ModelCardFullExpandable(
|
||||||
|
model = model,
|
||||||
|
isSelected = if (model == preselectedModel) true else null,
|
||||||
|
onSelected = { selected ->
|
||||||
|
if (selected) {
|
||||||
|
toggleSearchFocusAndIme(false)
|
||||||
|
} else {
|
||||||
|
viewModel.resetSelection()
|
||||||
|
toggleSearchFocusAndIme(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isExpanded = model == preselectedModel,
|
||||||
|
onExpanded = { expanded ->
|
||||||
|
viewModel.preselectModel(model, expanded)
|
||||||
|
toggleSearchFocusAndIme(!expanded)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (filteredModels.isEmpty()) {
|
||||||
|
EmptyModelsView(activeFiltersCount, onManageModelsClicked)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
Modifier.fillMaxSize(), // .padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
items(items = filteredModels, key = { it.id }) { model ->
|
||||||
ModelCardFullExpandable(
|
ModelCardFullExpandable(
|
||||||
model = model,
|
model = model,
|
||||||
isSelected = if (model == preselectedModel) true else null,
|
isSelected = if (model == preselectedModel) true else null,
|
||||||
|
|
@ -81,38 +184,56 @@ fun ModelSelectionScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show FAB if a model is selected
|
|
||||||
AnimatedVisibility(
|
|
||||||
modifier = Modifier.padding(16.dp).align(Alignment.BottomEnd),
|
|
||||||
visible = preselectedModel != null,
|
|
||||||
enter = scaleIn() + fadeIn(),
|
|
||||||
exit = scaleOut() + fadeOut()
|
|
||||||
) {
|
|
||||||
FloatingActionButton(
|
|
||||||
onClick = {
|
|
||||||
preselectedModel?.let {
|
|
||||||
viewModel.confirmSelectedModel(it)
|
|
||||||
onModelConfirmed(it)
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
containerColor = MaterialTheme.colorScheme.primary
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptySearchResultsView(
|
||||||
|
onClearSearch: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.PlayArrow,
|
imageVector = Icons.Default.SearchOff,
|
||||||
contentDescription = "Start with selected model"
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "No matching models found",
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Try a different search term",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(onClick = onClearSearch) {
|
||||||
|
Text("Clear Search")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyModelsView(onManageModelsClicked: () -> Unit) {
|
private fun EmptyModelsView(
|
||||||
|
activeFiltersCount: Int,
|
||||||
|
onManageModelsClicked: () -> Unit
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
|
|
@ -133,8 +254,12 @@ private fun EmptyModelsView(onManageModelsClicked: () -> Unit) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Add models to get started with local LLM inference",
|
text = when (activeFiltersCount) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
0 -> "Import some models to get started!"
|
||||||
|
1 -> "No models match the selected filter"
|
||||||
|
else -> "No models match the selected filters"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,179 @@
|
||||||
package com.example.llama.revamp.viewmodel
|
package com.example.llama.revamp.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.foundation.text.input.clearText
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.llama.revamp.data.model.ModelInfo
|
import com.example.llama.revamp.data.model.ModelInfo
|
||||||
import com.example.llama.revamp.data.repository.ModelRepository
|
import com.example.llama.revamp.data.repository.ModelRepository
|
||||||
import com.example.llama.revamp.engine.InferenceService
|
import com.example.llama.revamp.engine.InferenceService
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ModelSelectionViewModel @Inject constructor(
|
class ModelSelectionViewModel @Inject constructor(
|
||||||
private val inferenceService: InferenceService,
|
private val inferenceService: InferenceService,
|
||||||
modelRepository: ModelRepository
|
modelRepository: ModelRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
// UI state: search mode
|
||||||
|
private val _isSearchActive = MutableStateFlow(false)
|
||||||
|
val isSearchActive: StateFlow<Boolean> = _isSearchActive.asStateFlow()
|
||||||
|
|
||||||
|
fun toggleSearchState(active: Boolean) {
|
||||||
|
_isSearchActive.value = active
|
||||||
|
if (active) {
|
||||||
|
resetSelection()
|
||||||
|
} else {
|
||||||
|
searchFieldState.clearText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val searchFieldState = TextFieldState()
|
||||||
|
|
||||||
|
// UI state: sort menu
|
||||||
|
private val _sortOrder = MutableStateFlow(ModelSortOrder.LAST_USED)
|
||||||
|
val sortOrder: StateFlow<ModelSortOrder> = _sortOrder.asStateFlow()
|
||||||
|
|
||||||
|
fun setSortOrder(order: ModelSortOrder) {
|
||||||
|
_sortOrder.value = order
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _showSortMenu = MutableStateFlow(false)
|
||||||
|
val showSortMenu: StateFlow<Boolean> = _showSortMenu.asStateFlow()
|
||||||
|
|
||||||
|
fun toggleSortMenu(visible: Boolean) {
|
||||||
|
_showSortMenu.value = visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI state: filters
|
||||||
|
// TODO-han.yin: Refactor this into Enums!
|
||||||
|
private val _activeFilters = MutableStateFlow<Map<String, Boolean>>(mapOf(
|
||||||
|
"Has context length" to false,
|
||||||
|
"Support system prompt" to false,
|
||||||
|
"7B models" to false,
|
||||||
|
"13B models" to false,
|
||||||
|
"70B models" to false
|
||||||
|
))
|
||||||
|
val activeFilters: StateFlow<Map<String, Boolean>> = _activeFilters.asStateFlow()
|
||||||
|
|
||||||
|
fun toggleFilter(filter: String, enabled: Boolean) {
|
||||||
|
_activeFilters.update { current ->
|
||||||
|
current.toMutableMap().apply {
|
||||||
|
this[filter] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFilters() {
|
||||||
|
_activeFilters.update { current ->
|
||||||
|
current.mapValues { false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _showFilterMenu = MutableStateFlow(false)
|
||||||
|
val showFilterMenu: StateFlow<Boolean> = _showFilterMenu.asStateFlow()
|
||||||
|
|
||||||
|
fun toggleFilterMenu(visible: Boolean) {
|
||||||
|
_showFilterMenu.value = visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data: filtered & sorted models
|
||||||
|
private val _filteredModels = MutableStateFlow<List<ModelInfo>>(emptyList())
|
||||||
|
val filteredModels: StateFlow<List<ModelInfo>> = _filteredModels.asStateFlow()
|
||||||
|
|
||||||
|
// Data: queried models
|
||||||
|
private val _queryResults = MutableStateFlow<List<ModelInfo>>(emptyList())
|
||||||
|
val queryResults: StateFlow<List<ModelInfo>> = _queryResults.asStateFlow()
|
||||||
|
|
||||||
|
// Data: pre-selected model in expansion mode
|
||||||
private val _preselectedModel = MutableStateFlow<ModelInfo?>(null)
|
private val _preselectedModel = MutableStateFlow<ModelInfo?>(null)
|
||||||
val preselectedModel: StateFlow<ModelInfo?> = _preselectedModel.asStateFlow()
|
val preselectedModel: StateFlow<ModelInfo?> = _preselectedModel.asStateFlow()
|
||||||
|
|
||||||
/**
|
init {
|
||||||
* Available models for selection
|
viewModelScope.launch {
|
||||||
*/
|
combine(
|
||||||
val availableModels: StateFlow<List<ModelInfo>> = modelRepository.getModels()
|
modelRepository.getModels(),
|
||||||
.stateIn(
|
_activeFilters,
|
||||||
scope = viewModelScope,
|
_sortOrder,
|
||||||
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
|
) { models, filters, sortOrder ->
|
||||||
initialValue = emptyList()
|
models.filterBy(filters).sortByOrder(sortOrder)
|
||||||
|
}.collect {
|
||||||
|
_filteredModels.value = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
modelRepository.getModels(),
|
||||||
|
snapshotFlow { searchFieldState.text }.debounce(QUERY_DEBOUNCE_TIMEOUT_MS)
|
||||||
|
) { models, query ->
|
||||||
|
if (query.isBlank()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
models.queryBy(query.toString()).sortedBy { it.dateLastUsed ?: it.dateAdded }
|
||||||
|
}
|
||||||
|
}.collectLatest {
|
||||||
|
_queryResults.value = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<ModelInfo>.queryBy(query: String): List<ModelInfo> {
|
||||||
|
if (query.isBlank()) return this
|
||||||
|
|
||||||
|
return filter { model ->
|
||||||
|
model.name.contains(query, ignoreCase = true) ||
|
||||||
|
model.metadata.fullModelName?.contains(query, ignoreCase = true) == true ||
|
||||||
|
model.metadata.additional?.tags?.any { it.contains(query, ignoreCase = true) } == true ||
|
||||||
|
model.metadata.additional?.languages?.any { it.contains(query, ignoreCase = true) } == true ||
|
||||||
|
model.metadata.architecture?.architecture?.contains(query, ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO-han.yin: Refactor this into Enums!
|
||||||
|
private fun List<ModelInfo>.filterBy(filters: Map<String, Boolean>): List<ModelInfo> {
|
||||||
|
val activeFilters = filters.filterValues { it }
|
||||||
|
if (activeFilters.isEmpty()) return this
|
||||||
|
|
||||||
|
return filter { model ->
|
||||||
|
activeFilters.all { (filter, _) ->
|
||||||
|
when (filter) {
|
||||||
|
"Has context length" -> model.metadata.dimensions?.contextLength != null
|
||||||
|
"Support system prompt" -> true
|
||||||
|
"7B models" -> model.metadata.basic.sizeLabel?.contains("7B") == true
|
||||||
|
"13B models" -> model.metadata.basic.sizeLabel?.contains("13B") == true
|
||||||
|
"70B models" -> model.metadata.basic.sizeLabel?.contains("70B") == true
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<ModelInfo>.sortByOrder(order: ModelSortOrder): List<ModelInfo> {
|
||||||
|
return when (order) {
|
||||||
|
ModelSortOrder.NAME_ASC -> sortedBy { it.name }
|
||||||
|
ModelSortOrder.NAME_DESC -> sortedByDescending { it.name }
|
||||||
|
ModelSortOrder.SIZE_ASC -> sortedBy { it.sizeInBytes }
|
||||||
|
ModelSortOrder.SIZE_DESC -> sortedByDescending { it.sizeInBytes }
|
||||||
|
ModelSortOrder.LAST_USED -> sortedWith(
|
||||||
|
compareByDescending<ModelInfo> { it.dateLastUsed }
|
||||||
|
.thenBy { it.name }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-select a model
|
* Pre-select a model
|
||||||
|
|
@ -67,6 +208,6 @@ class ModelSelectionViewModel @Inject constructor(
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = ModelSelectionViewModel::class.java.simpleName
|
private val TAG = ModelSelectionViewModel::class.java.simpleName
|
||||||
|
|
||||||
private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
|
private const val QUERY_DEBOUNCE_TIMEOUT_MS = 500L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue