UI: merge the Model Selection and Model Management into a unified Models screen

This commit is contained in:
Han Yin 2025-08-30 15:46:56 -07:00
parent 29f263440f
commit df16abe75e
25 changed files with 1562 additions and 1319 deletions

View File

@ -46,17 +46,16 @@ import com.example.llama.ui.scaffold.topbar.TopBarConfig
import com.example.llama.ui.screens.BenchmarkScreen
import com.example.llama.ui.screens.ConversationScreen
import com.example.llama.ui.screens.ModelLoadingScreen
import com.example.llama.ui.screens.ModelSelectionScreen
import com.example.llama.ui.screens.ModelsManagementScreen
import com.example.llama.ui.screens.ModelsScreen
import com.example.llama.ui.screens.SettingsGeneralScreen
import com.example.llama.ui.theme.LlamaTheme
import com.example.llama.viewmodel.BenchmarkViewModel
import com.example.llama.viewmodel.ConversationViewModel
import com.example.llama.viewmodel.MainViewModel
import com.example.llama.viewmodel.ModelLoadingViewModel
import com.example.llama.viewmodel.ModelSelectionViewModel
import com.example.llama.viewmodel.ModelsManagementViewModel
import com.example.llama.viewmodel.ModelsViewModel
import com.example.llama.viewmodel.SettingsViewModel
import com.example.llama.viewmodel.ModelScreenUiMode
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@ -85,11 +84,11 @@ class MainActivity : ComponentActivity() {
fun AppContent(
settingsViewModel: SettingsViewModel,
mainViewModel: MainViewModel = hiltViewModel(),
modelSelectionViewModel: ModelSelectionViewModel = hiltViewModel(),
modelsViewModel: ModelsViewModel = hiltViewModel(),
modelLoadingViewModel: ModelLoadingViewModel = hiltViewModel(),
benchmarkViewModel: BenchmarkViewModel = hiltViewModel(),
conversationViewModel: ConversationViewModel = hiltViewModel(),
modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
// modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
) {
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
@ -97,6 +96,9 @@ fun AppContent(
// Inference engine state
val engineState by mainViewModel.engineState.collectAsState()
// Model state
val modelScreenUiMode by modelsViewModel.modelScreenUiMode.collectAsState()
// Metric states for scaffolds
val isMonitoringEnabled by settingsViewModel.isMonitoringEnabled.collectAsState()
val memoryUsage by settingsViewModel.memoryUsage.collectAsState()
@ -187,60 +189,157 @@ fun AppContent(
// Create scaffold's top & bottom bar configs based on current route
val scaffoldConfig = when {
// Model selection screen
currentRoute == AppDestinations.MODEL_SELECTION_ROUTE -> {
currentRoute == AppDestinations.MODELS_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 preselection by modelSelectionViewModel.preselection.collectAsState()
val sortOrder by modelsViewModel.sortOrder.collectAsState()
val showSortMenu by modelsViewModel.showSortMenu.collectAsState()
val activeFilters by modelsViewModel.activeFilters.collectAsState()
val showFilterMenu by modelsViewModel.showFilterMenu.collectAsState()
val preselection by modelsViewModel.preselectedModelToRun.collectAsState()
val selectedModelsToDelete by modelsViewModel.selectedModelsToDelete.collectAsState()
val showImportModelMenu by modelsViewModel.showImportModelMenu.collectAsState()
// Create file launcher for importing local models
val fileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { modelsViewModel.importLocalModelFileSelected(it) } }
ScaffoldConfig(
topBarConfig =
if (isSearchActive) TopBarConfig.None()
else TopBarConfig.Default(
title = "Pick your model",
navigationIcon = NavigationIcon.Menu {
modelSelectionViewModel.resetPreselection()
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(
preselection = preselection,
onClickRun = {
if (modelSelectionViewModel.selectModel(it)) {
modelSelectionViewModel.toggleSearchState(false)
modelSelectionViewModel.resetPreselection()
navigationActions.navigateToModelLoading()
}
}
)
)
when (modelScreenUiMode) {
ModelScreenUiMode.BROWSING ->
TopBarConfig.ModelsBrowsing(
title = "Installed models",
navigationIcon = NavigationIcon.Menu {
modelsViewModel.resetPreselection()
openDrawer()
},
onToggleMode = modelsViewModel::toggleMode,
)
ModelScreenUiMode.SEARCHING ->
TopBarConfig.None()
ModelScreenUiMode.MANAGING ->
TopBarConfig.ModelsManagement(
title = "Managing models",
navigationIcon = NavigationIcon.Back {
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
},
storageMetrics = if (isMonitoringEnabled) storageMetrics else null,
)
ModelScreenUiMode.DELETING ->
TopBarConfig.ModelsDeleting(
title = "Deleting models",
navigationIcon = NavigationIcon.Quit {
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
},
)
},
bottomBarConfig =
when (modelScreenUiMode) {
ModelScreenUiMode.BROWSING ->
BottomBarConfig.Models.Browsing(
onToggleSearching = {
modelsViewModel.toggleMode(ModelScreenUiMode.SEARCHING)
},
sorting = BottomBarConfig.Models.Browsing.SortingConfig(
currentOrder = sortOrder,
isMenuVisible = showSortMenu,
toggleMenu = modelsViewModel::toggleSortMenu,
selectOrder = {
modelsViewModel.setSortOrder(it)
modelsViewModel.toggleSortMenu(false)
}
),
filtering = BottomBarConfig.Models.Browsing.FilteringConfig(
isActive = activeFilters.any { it.value },
filters = activeFilters,
onToggleFilter = modelsViewModel::toggleFilter,
onClearFilters = modelsViewModel::clearFilters,
isMenuVisible = showFilterMenu,
toggleMenu = modelsViewModel::toggleFilterMenu
),
runAction = BottomBarConfig.Models.RunActionConfig(
preselectedModelToRun = preselection,
onClickRun = {
if (modelsViewModel.selectModel(it)) {
modelsViewModel.resetPreselection()
navigationActions.navigateToModelLoading()
}
}
),
)
ModelScreenUiMode.SEARCHING ->
BottomBarConfig.Models.Searching(
textFieldState = modelsViewModel.searchFieldState,
onQuitSearching = {
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
},
onSearch = { /* No-op for now */ },
runAction = BottomBarConfig.Models.RunActionConfig(
preselectedModelToRun = preselection,
onClickRun = {
if (modelsViewModel.selectModel(it)) {
modelsViewModel.resetPreselection()
navigationActions.navigateToModelLoading()
}
}
),
)
ModelScreenUiMode.MANAGING ->
BottomBarConfig.Models.Management(
sorting = BottomBarConfig.Models.Management.SortingConfig(
currentOrder = sortOrder,
isMenuVisible = showSortMenu,
toggleMenu = { modelsViewModel.toggleSortMenu(it) },
selectOrder = {
modelsViewModel.setSortOrder(it)
modelsViewModel.toggleSortMenu(false)
}
),
filtering = BottomBarConfig.Models.Management.FilteringConfig(
isActive = activeFilters.any { it.value },
filters = activeFilters,
onToggleFilter = modelsViewModel::toggleFilter,
onClearFilters = modelsViewModel::clearFilters,
isMenuVisible = showFilterMenu,
toggleMenu = modelsViewModel::toggleFilterMenu
),
importing = BottomBarConfig.Models.Management.ImportConfig(
isMenuVisible = showImportModelMenu,
toggleMenu = { show -> modelsViewModel.toggleImportMenu(show) },
importFromLocal = {
fileLauncher.launch(arrayOf("application/octet-stream", "*/*"))
modelsViewModel.toggleImportMenu(false)
},
importFromHuggingFace = {
modelsViewModel.queryModelsFromHuggingFace()
modelsViewModel.toggleImportMenu(false)
}
),
onToggleDeleting = {
modelsViewModel.toggleMode(ModelScreenUiMode.DELETING)
}
)
ModelScreenUiMode.DELETING ->
BottomBarConfig.Models.Deleting(
onQuitDeleting = {
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
},
selectedModels = selectedModelsToDelete,
toggleAllSelection = { modelsViewModel.toggleAllSelectedModelsToDelete(it) },
deleteSelected = {
selectedModelsToDelete.let {
if (it.isNotEmpty()) {
modelsViewModel.batchDeletionClicked(it)
}
}
},
)
}
)
}
@ -327,75 +426,6 @@ fun AppContent(
)
)
// Storage management screen
currentRoute == AppDestinations.MODELS_MANAGEMENT_ROUTE -> {
// Collect the needed states
val sortOrder by modelsManagementViewModel.sortOrder.collectAsState()
val isMultiSelectionMode by modelsManagementViewModel.isMultiSelectionMode.collectAsState()
val selectedModels by modelsManagementViewModel.selectedModels.collectAsState()
val showSortMenu by modelsManagementViewModel.showSortMenu.collectAsState()
val activeFilters by modelsManagementViewModel.activeFilters.collectAsState()
val showFilterMenu by modelsManagementViewModel.showFilterMenu.collectAsState()
val showImportModelMenu by modelsManagementViewModel.showImportModelMenu.collectAsState()
// Create file launcher for importing local models
val fileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { modelsManagementViewModel.importLocalModelFileSelected(it) } }
val bottomBarConfig = BottomBarConfig.ModelsManagement(
sorting = BottomBarConfig.ModelsManagement.SortingConfig(
currentOrder = sortOrder,
isMenuVisible = showSortMenu,
toggleMenu = { modelsManagementViewModel.toggleSortMenu(it) },
selectOrder = {
modelsManagementViewModel.setSortOrder(it)
modelsManagementViewModel.toggleSortMenu(false)
}
),
filtering = BottomBarConfig.ModelsManagement.FilteringConfig(
isActive = activeFilters.any { it.value },
filters = activeFilters,
onToggleFilter = modelsManagementViewModel::toggleFilter,
onClearFilters = modelsManagementViewModel::clearFilters,
isMenuVisible = showFilterMenu,
toggleMenu = modelsManagementViewModel::toggleFilterMenu
),
selection = BottomBarConfig.ModelsManagement.SelectionConfig(
isActive = isMultiSelectionMode,
toggleMode = { modelsManagementViewModel.toggleSelectionMode(it) },
selectedModels = selectedModels,
toggleAllSelection = { modelsManagementViewModel.toggleAllSelection(it) },
deleteSelected = {
if (selectedModels.isNotEmpty()) {
modelsManagementViewModel.batchDeletionClicked(selectedModels)
}
},
),
importing = BottomBarConfig.ModelsManagement.ImportConfig(
isMenuVisible = showImportModelMenu,
toggleMenu = { show -> modelsManagementViewModel.toggleImportMenu(show) },
importFromLocal = {
fileLauncher.launch(arrayOf("application/octet-stream", "*/*"))
modelsManagementViewModel.toggleImportMenu(false)
},
importFromHuggingFace = {
modelsManagementViewModel.queryModelsFromHuggingFace()
modelsManagementViewModel.toggleImportMenu(false)
}
)
)
ScaffoldConfig(
topBarConfig = TopBarConfig.Storage(
title = "Models Management",
navigationIcon = NavigationIcon.Back { navigationActions.navigateUp() },
storageMetrics = if (isMonitoringEnabled) storageMetrics else null,
),
bottomBarConfig = bottomBarConfig
)
}
// Fallback for empty screen or unknown routes
else -> ScaffoldConfig(
topBarConfig = TopBarConfig.Default(title = "", navigationIcon = NavigationIcon.None)
@ -419,22 +449,22 @@ fun AppContent(
// AnimatedNavHost inside the scaffold content
AnimatedNavHost(
navController = navController,
startDestination = AppDestinations.MODEL_SELECTION_ROUTE,
startDestination = AppDestinations.MODELS_ROUTE,
modifier = Modifier.padding(paddingValues)
) {
// Model Selection Screen
composable(AppDestinations.MODEL_SELECTION_ROUTE) {
ModelSelectionScreen(
composable(AppDestinations.MODELS_ROUTE) {
ModelsScreen(
onManageModelsClicked = {
navigationActions.navigateToModelsManagement()
// TODO-han.yin: remove this after implementing onboarding flow
},
onConfirmSelection = { modelInfo, ramWarning ->
if (modelSelectionViewModel.confirmSelectedModel(modelInfo, ramWarning)) {
if (modelsViewModel.confirmSelectedModel(modelInfo, ramWarning)) {
navigationActions.navigateToModelLoading()
modelSelectionViewModel.toggleSearchState(false)
}
},
viewModel = modelSelectionViewModel
onScaffoldEvent = handleScaffoldEvent,
viewModel = modelsViewModel
)
}
@ -502,14 +532,6 @@ fun AppContent(
)
}
// Models Management Screen
composable(AppDestinations.MODELS_MANAGEMENT_ROUTE) {
ModelsManagementScreen(
onScaffoldEvent = handleScaffoldEvent,
viewModel = modelsManagementViewModel
)
}
// General Settings Screen
composable(AppDestinations.SETTINGS_GENERAL_ROUTE) {
SettingsGeneralScreen(

View File

@ -8,7 +8,7 @@ import com.example.llama.engine.ModelLoadingMetrics
*/
object AppDestinations {
// Primary navigation destinations
const val MODEL_SELECTION_ROUTE = "model_selection"
const val MODELS_ROUTE = "models"
const val MODEL_LOADING_ROUTE = "model_loading"
const val CONVERSATION_ROUTE = "conversation"
@ -19,7 +19,6 @@ object AppDestinations {
// Settings destinations
const val SETTINGS_GENERAL_ROUTE = "settings_general"
const val MODELS_MANAGEMENT_ROUTE = "models_management"
}
/**
@ -28,9 +27,9 @@ object AppDestinations {
class NavigationActions(private val navController: NavController) {
fun navigateToModelSelection() {
navController.navigate(AppDestinations.MODEL_SELECTION_ROUTE) {
navController.navigate(AppDestinations.MODELS_ROUTE) {
// Clear back stack to start fresh
popUpTo(AppDestinations.MODEL_SELECTION_ROUTE) { inclusive = true }
popUpTo(AppDestinations.MODELS_ROUTE) { inclusive = true }
}
}
@ -55,10 +54,6 @@ class NavigationActions(private val navController: NavController) {
navController.navigate(AppDestinations.SETTINGS_GENERAL_ROUTE)
}
fun navigateToModelsManagement() {
navController.navigate(AppDestinations.MODELS_MANAGEMENT_ROUTE)
}
fun navigateUp() {
navController.navigateUp()
}

View File

@ -32,7 +32,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -149,7 +148,6 @@ fun ModelCardCoreExpandable(
* @param isExpanded Whether additional details is expanded or shrunk
* @param onExpanded Action to perform when the card is expanded or shrunk
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ModelCardFullExpandable(
model: ModelInfo,
@ -158,10 +156,6 @@ fun ModelCardFullExpandable(
isExpanded: Boolean = false,
onExpanded: ((Boolean) -> Unit)? = null,
) {
LaunchedEffect(model) {
android.util.Log.w("JOJO", model.languages.toString())
}
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Card(
modifier = Modifier

View File

@ -10,9 +10,12 @@ import androidx.compose.runtime.remember
import com.example.llama.ui.scaffold.bottombar.BenchmarkBottomBar
import com.example.llama.ui.scaffold.bottombar.BottomBarConfig
import com.example.llama.ui.scaffold.bottombar.ConversationBottomBar
import com.example.llama.ui.scaffold.bottombar.ModelSelectionBottomBar
import com.example.llama.ui.scaffold.bottombar.ModelsBrowsingBottomBar
import com.example.llama.ui.scaffold.bottombar.ModelsDeletingBottomBar
import com.example.llama.ui.scaffold.bottombar.ModelsManagementBottomBar
import com.example.llama.ui.scaffold.bottombar.ModelsSearchingBottomBar
import com.example.llama.ui.scaffold.topbar.DefaultTopBar
import com.example.llama.ui.scaffold.topbar.ModelsBrowsingTopBar
import com.example.llama.ui.scaffold.topbar.NavigationIcon
import com.example.llama.ui.scaffold.topbar.PerformanceTopBar
import com.example.llama.ui.scaffold.topbar.StorageTopBar
@ -65,6 +68,25 @@ fun AppScaffold(
onMenuOpen = topBarconfig.navigationIcon.menuAction,
)
is TopBarConfig.ModelsBrowsing -> ModelsBrowsingTopBar(
title = topBarconfig.title,
onToggleMode = topBarconfig.onToggleMode,
onNavigateBack = topBarconfig.navigationIcon.backAction,
onMenuOpen = topBarconfig.navigationIcon.menuAction
)
is TopBarConfig.ModelsDeleting -> DefaultTopBar(
title = topBarconfig.title,
onQuit = topBarconfig.navigationIcon.quitAction
)
is TopBarConfig.ModelsManagement -> StorageTopBar(
title = topBarconfig.title,
storageMetrics = topBarconfig.storageMetrics,
onScaffoldEvent = onScaffoldEvent,
onNavigateBack = topBarconfig.navigationIcon.backAction,
)
is TopBarConfig.Performance -> PerformanceTopBar(
title = topBarconfig.title,
memoryMetrics = topBarconfig.memoryMetrics,
@ -73,13 +95,6 @@ fun AppScaffold(
onNavigateBack = topBarconfig.navigationIcon.backAction,
onMenuOpen = topBarconfig.navigationIcon.menuAction,
)
is TopBarConfig.Storage -> StorageTopBar(
title = topBarconfig.title,
storageMetrics = topBarconfig.storageMetrics,
onScaffoldEvent = onScaffoldEvent,
onNavigateBack = topBarconfig.navigationIcon.backAction,
)
}
}
@ -87,24 +102,37 @@ fun AppScaffold(
when (val config = bottomBarConfig) {
is BottomBarConfig.None -> { /* No bottom bar */ }
is BottomBarConfig.ModelSelection -> {
ModelSelectionBottomBar(
search = config.search,
sorting = config.sorting,
filtering = config.filtering,
runAction = config.runAction
is BottomBarConfig.Models.Browsing -> {
ModelsBrowsingBottomBar(
onToggleSearching = config.onToggleSearching,
sortingConfig = config.sorting,
filteringConfig = config.filtering,
runActionConfig = config.runAction
)
}
is BottomBarConfig.ModelsManagement -> {
ModelsManagementBottomBar(
sorting = config.sorting,
filtering = config.filtering,
selection = config.selection,
importing = config.importing,
is BottomBarConfig.Models.Searching -> {
ModelsSearchingBottomBar(
textFieldState = config.textFieldState,
onQuitSearching = config.onQuitSearching,
onSearch = config.onSearch,
runActionConfig = config.runAction,
)
}
is BottomBarConfig.Models.Management -> {
ModelsManagementBottomBar(
sortingConfig = config.sorting,
filteringConfig = config.filtering,
importingConfig = config.importing,
onToggleDeleting = config.onToggleDeleting
)
}
is BottomBarConfig.Models.Deleting -> {
ModelsDeletingBottomBar(config)
}
is BottomBarConfig.Benchmark -> {
BenchmarkBottomBar(
showShareFab = config.showShareFab,
@ -141,8 +169,11 @@ fun AppScaffold(
}
// Helper functions to obtain navigation actions if exist
private val NavigationIcon.menuAction: (() -> Unit)?
get() = (this as? NavigationIcon.Menu)?.onMenuOpen
private val NavigationIcon.backAction: (() -> Unit)?
get() = (this as? NavigationIcon.Back)?.onNavigateBack
private val NavigationIcon.menuAction: (() -> Unit)?
get() = (this as? NavigationIcon.Menu)?.onMenuOpen
private val NavigationIcon.quitAction: (() -> Unit)?
get() = (this as? NavigationIcon.Quit)?.onQuit

View File

@ -122,20 +122,21 @@ private fun DrawerContent(
Spacer(modifier = Modifier.height(16.dp))
// Main Navigation Items
Text(
text = "Navigation",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
)
// // Main Navigation Items
// TODO-han.yin: add back once we add more features
// Text(
// text = "Features",
// style = MaterialTheme.typography.labelMedium,
// color = MaterialTheme.colorScheme.onSurfaceVariant,
// modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
// )
DrawerNavigationItem(
icon = Icons.Default.Home,
label = "Home",
isSelected = currentRoute == AppDestinations.MODEL_SELECTION_ROUTE,
label = "Models",
isSelected = currentRoute == AppDestinations.MODELS_ROUTE,
onClick = {
if (currentRoute != AppDestinations.MODEL_SELECTION_ROUTE) {
if (currentRoute != AppDestinations.MODELS_ROUTE) {
onNavigate { navigationActions.navigateToModelSelection() }
} else {
onNavigate { /* No-op: simply close drawer */ }
@ -143,22 +144,15 @@ private fun DrawerContent(
}
)
Spacer(modifier = Modifier.height(24.dp))
// Settings Group
Text(
text = "Settings",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
)
DrawerNavigationItem(
icon = Icons.Default.Folder,
label = "Models",
isSelected = currentRoute == AppDestinations.MODELS_MANAGEMENT_ROUTE,
onClick = { onNavigate { navigationActions.navigateToModelsManagement() } }
)
// Spacer(modifier = Modifier.height(24.dp))
// TODO-han.yin: add back once we add more features
// // Settings Group
// Text(
// text = "Settings",
// style = MaterialTheme.typography.labelMedium,
// color = MaterialTheme.colorScheme.onSurfaceVariant,
// modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
// )
DrawerNavigationItem(
icon = Icons.Default.Settings,

View File

@ -64,10 +64,7 @@ fun BenchmarkBottomBar(
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
onClick = onShare,
containerColor = MaterialTheme.colorScheme.primary
) {
FloatingActionButton(onClick = onShare) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share the benchmark results"

View File

@ -4,7 +4,7 @@ import androidx.compose.foundation.text.input.TextFieldState
import com.example.llama.data.model.ModelFilter
import com.example.llama.data.model.ModelInfo
import com.example.llama.data.model.ModelSortOrder
import com.example.llama.viewmodel.Preselection
import com.example.llama.viewmodel.PreselectedModelToRun
/**
* [BottomAppBar] configurations
@ -13,76 +13,78 @@ sealed class 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,
sealed class Models : BottomBarConfig() {
data class Browsing(
val onToggleSearching: () -> Unit,
val sorting: SortingConfig,
val filtering: FilteringConfig,
val runAction: RunActionConfig,
) : BottomBarConfig() {
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<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit
)
}
data class Searching(
val textFieldState: TextFieldState,
val onQuitSearching: () -> Unit,
val onSearch: (String) -> Unit,
)
val runAction: RunActionConfig,
) : BottomBarConfig()
data class SortingConfig(
val currentOrder: ModelSortOrder,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val selectOrder: (ModelSortOrder) -> Unit
)
data class Management(
val sorting: SortingConfig,
val filtering: FilteringConfig,
val importing: ImportConfig,
val onToggleDeleting: () -> Unit,
) : BottomBarConfig() {
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<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit
)
data class FilteringConfig(
val isActive: Boolean,
val filters: Map<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit
)
data class RunActionConfig(
val preselection: Preselection?,
val onClickRun: (Preselection) -> Unit,
)
}
data class ImportConfig(
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val importFromLocal: () -> Unit,
val importFromHuggingFace: () -> Unit
)
}
data class ModelsManagement(
val sorting: SortingConfig,
val filtering: FilteringConfig,
val selection: SelectionConfig,
val importing: ImportConfig
) : BottomBarConfig() {
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<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit
)
data class SelectionConfig(
val isActive: Boolean,
val toggleMode: (Boolean) -> Unit,
data class Deleting(
val onQuitDeleting: () -> Unit,
val selectedModels: Map<String, ModelInfo>,
val toggleAllSelection: (Boolean) -> Unit,
val deleteSelected: () -> Unit
)
) : BottomBarConfig()
data class ImportConfig(
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val importFromLocal: () -> Unit,
val importFromHuggingFace: () -> Unit
data class RunActionConfig(
val preselectedModelToRun: PreselectedModelToRun?,
val onClickRun: (PreselectedModelToRun) -> Unit,
)
}

View File

@ -1,192 +0,0 @@
package com.example.llama.ui.scaffold.bottombar
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.padding
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.automirrored.outlined.Backspace
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.FilterAlt
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.outlined.FilterAlt
import androidx.compose.material.icons.outlined.FilterAltOff
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.llama.data.model.ModelSortOrder
@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.displayName) },
leadingIcon = {
Checkbox(
checked = isEnabled,
onCheckedChange = null
)
},
onClick = { filtering.onToggleFilter(filter, !isEnabled) }
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clear filters") },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.FilterAltOff,
contentDescription = "Clear all filters"
)
},
onClick = {
filtering.onClearFilters()
filtering.toggleMenu(false)
}
)
}
}
},
floatingActionButton = {
// Only show FAB if a model is selected
AnimatedVisibility(
visible = runAction.preselection != null,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
onClick = { runAction.preselection?.let { runAction.onClickRun(it) } },
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Run with selected model"
)
}
}
}
)
}

View File

@ -0,0 +1,174 @@
package com.example.llama.ui.scaffold.bottombar
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.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.FilterAltOff
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.llama.data.model.ModelSortOrder
@Composable
fun ModelsBrowsingBottomBar(
onToggleSearching: () -> Unit,
sortingConfig: BottomBarConfig.Models.Browsing.SortingConfig,
filteringConfig: BottomBarConfig.Models.Browsing.FilteringConfig,
runActionConfig: BottomBarConfig.Models.RunActionConfig,
) {
BottomAppBar(
actions = {
// Enter search action
IconButton(onClick = onToggleSearching) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search models"
)
}
// Sorting action
IconButton(onClick = { sortingConfig.toggleMenu(true) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = "Sort models"
)
}
// Sorting dropdown menu
DropdownMenu(
expanded = sortingConfig.isMenuVisible,
onDismissRequest = { sortingConfig.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 (sortingConfig.currentOrder == order)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "$description, selected"
)
},
onClick = { sortingConfig.selectOrder(order) }
)
}
}
// Filter action
IconButton(onClick = { filteringConfig.toggleMenu(true) }) {
Icon(
imageVector =
if (filteringConfig.isActive) Icons.Default.FilterAlt
else Icons.Outlined.FilterAlt,
contentDescription = "Filter models"
)
}
// Filter dropdown menu
DropdownMenu(
expanded = filteringConfig.isMenuVisible,
onDismissRequest = { filteringConfig.toggleMenu(false) }
) {
Text(
text = "Filter by",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
filteringConfig.filters.forEach { (filter, isEnabled) ->
DropdownMenuItem(
text = { Text(filter.displayName) },
leadingIcon = {
Checkbox(
checked = isEnabled,
onCheckedChange = null
)
},
onClick = { filteringConfig.onToggleFilter(filter, !isEnabled) }
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clear filters") },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.FilterAltOff,
contentDescription = "Clear all filters"
)
},
onClick = {
filteringConfig.onClearFilters()
filteringConfig.toggleMenu(false)
}
)
}
},
floatingActionButton = {
// Only show FAB if a model is selected
AnimatedVisibility(
visible = runActionConfig.preselectedModelToRun != null,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
onClick = {
runActionConfig.preselectedModelToRun?.let {
runActionConfig.onClickRun(it)
}
},
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Run with selected model"
)
}
}
}
)
}

View File

@ -0,0 +1,61 @@
package com.example.llama.ui.scaffold.bottombar
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.material.icons.Icons
import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@Composable
fun ModelsDeletingBottomBar(
deleting: BottomBarConfig.Models.Deleting,
) {
BottomAppBar(
actions = {
IconButton(onClick = { deleting.toggleAllSelection(false) }) {
Icon(
imageVector = Icons.Default.ClearAll,
contentDescription = "Deselect all"
)
}
IconButton(onClick = { deleting.toggleAllSelection(true) }) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = "Select all"
)
}
},
floatingActionButton = {
AnimatedVisibility(
visible = deleting.selectedModels.isNotEmpty(),
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
onClick = {
deleting.deleteSelected()
},
containerColor = MaterialTheme.colorScheme.error
) {
Icon(
imageVector = Icons.Default.DeleteForever,
contentDescription = "Delete selected models",
tint = MaterialTheme.colorScheme.onError,
)
}
}
}
)
}

View File

@ -6,12 +6,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.FilterAltOff
@ -35,176 +31,138 @@ import com.example.llama.data.model.ModelSortOrder
@Composable
fun ModelsManagementBottomBar(
sorting: BottomBarConfig.ModelsManagement.SortingConfig,
filtering: BottomBarConfig.ModelsManagement.FilteringConfig,
selection: BottomBarConfig.ModelsManagement.SelectionConfig,
importing: BottomBarConfig.ModelsManagement.ImportConfig
sortingConfig: BottomBarConfig.Models.Management.SortingConfig,
filteringConfig: BottomBarConfig.Models.Management.FilteringConfig,
importingConfig: BottomBarConfig.Models.Management.ImportConfig,
onToggleDeleting: () -> Unit,
) {
BottomAppBar(
actions = {
if (selection.isActive) {
/* Multi-selection mode actions */
IconButton(
onClick = selection.deleteSelected,
enabled = selection.selectedModels.isNotEmpty()
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete selected",
tint = if (selection.selectedModels.isNotEmpty())
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
// Batch-deletion action
IconButton(onClick = onToggleDeleting) {
Icon(
imageVector = Icons.Outlined.DeleteSweep,
contentDescription = "Delete models"
)
}
IconButton(onClick = { selection.toggleAllSelection(false) }) {
Icon(
imageVector = Icons.Default.ClearAll,
contentDescription = "Deselect all"
)
}
// Sorting action
IconButton(onClick = { sortingConfig.toggleMenu(true) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = "Sort models"
)
}
IconButton(onClick = { selection.toggleAllSelection(true) }) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = "Select all"
)
}
} else {
/* Default mode actions */
// Multi-selection action
IconButton(onClick = { selection.toggleMode(true) }) {
Icon(
imageVector = Icons.Outlined.DeleteSweep,
contentDescription = "Delete 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) }
)
}
}
// Filtering 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.displayName) },
leadingIcon = {
Checkbox(
checked = isEnabled,
onCheckedChange = null
)
},
onClick = { filtering.onToggleFilter(filter, !isEnabled) }
)
}
HorizontalDivider()
// Sorting dropdown menu
DropdownMenu(
expanded = sortingConfig.isMenuVisible,
onDismissRequest = { sortingConfig.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("Clear filters") },
text = { Text(label) },
trailingIcon = {
if (sortingConfig.currentOrder == order)
Icon(
imageVector = Icons.Default.Check,
contentDescription = "$description, selected"
)
},
onClick = { sortingConfig.selectOrder(order) }
)
}
}
// Filtering action
IconButton(onClick = { filteringConfig.toggleMenu(true) }) {
Icon(
imageVector =
if (filteringConfig.isActive) Icons.Default.FilterAlt
else Icons.Outlined.FilterAlt,
contentDescription = "Filter models"
)
}
// Filter dropdown menu
DropdownMenu(
expanded = filteringConfig.isMenuVisible,
onDismissRequest = { filteringConfig.toggleMenu(false) }
) {
Text(
text = "Filter by",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
filteringConfig.filters.forEach { (filter, isEnabled) ->
DropdownMenuItem(
text = { Text(filter.displayName) },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.FilterAltOff,
contentDescription = "Clear all filters"
Checkbox(
checked = isEnabled,
onCheckedChange = null
)
},
onClick = {
filtering.onClearFilters()
filtering.toggleMenu(false)
}
onClick = { filteringConfig.onToggleFilter(filter, !isEnabled) }
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clear filters") },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.FilterAltOff,
contentDescription = "Clear all filters"
)
},
onClick = {
filteringConfig.onClearFilters()
filteringConfig.toggleMenu(false)
}
)
}
},
floatingActionButton = {
FloatingActionButton(
onClick = {
if (selection.isActive) selection.toggleMode(false) else importing.toggleMenu(
true
)
},
containerColor = MaterialTheme.colorScheme.primaryContainer
onClick = { importingConfig.toggleMenu(true) },
) {
Icon(
imageVector = if (selection.isActive) Icons.Default.Close else Icons.Default.Add,
contentDescription = if (selection.isActive) "Exit selection mode" else "Add model"
imageVector = Icons.Default.Add,
contentDescription = "Add model"
)
}
// Add model dropdown menu
DropdownMenu(
expanded = importing.isMenuVisible,
onDismissRequest = { importing.toggleMenu(false) }
expanded = importingConfig.isMenuVisible,
onDismissRequest = { importingConfig.toggleMenu(false) }
) {
DropdownMenuItem(
text = { Text("Import local model") },
@ -214,7 +172,7 @@ fun ModelsManagementBottomBar(
contentDescription = "Import a local model on the device"
)
},
onClick = importing.importFromLocal
onClick = importingConfig.importFromLocal
)
DropdownMenuItem(
text = { Text("Download from HuggingFace") },
@ -226,7 +184,7 @@ fun ModelsManagementBottomBar(
tint = Color.Unspecified,
)
},
onClick = importing.importFromHuggingFace
onClick = importingConfig.importFromHuggingFace
)
}
}

View File

@ -0,0 +1,67 @@
package com.example.llama.ui.scaffold.bottombar
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.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Backspace
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SearchOff
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
@Composable
fun ModelsSearchingBottomBar(
textFieldState: TextFieldState,
onQuitSearching: () -> Unit,
onSearch: (String) -> Unit, // TODO-han.yin: somehow this is unused?
runActionConfig: BottomBarConfig.Models.RunActionConfig,
) {
BottomAppBar(
actions = {
// Quit search action
IconButton(onClick = onQuitSearching) {
Icon(
imageVector = Icons.Default.SearchOff,
contentDescription = "Quit search mode"
)
}
// Clear query action
IconButton(onClick = { textFieldState.clearText() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Backspace,
contentDescription = "Clear query text"
)
}
},
floatingActionButton = {
// Only show FAB if a model is selected
AnimatedVisibility(
visible = runActionConfig.preselectedModelToRun != null,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton(
onClick = {
runActionConfig.preselectedModelToRun?.let {
runActionConfig.onClickRun(it)
}
},
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Run with selected model"
)
}
}
}
)
}

View File

@ -2,6 +2,7 @@ package com.example.llama.ui.scaffold.topbar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -17,12 +18,22 @@ import androidx.compose.runtime.Composable
fun DefaultTopBar(
title: String,
onNavigateBack: (() -> Unit)? = null,
onQuit: (() -> Unit)? = null,
onMenuOpen: (() -> Unit)? = null
) {
TopAppBar(
title = { Text(title) },
navigationIcon = {
when {
onQuit != null -> {
IconButton(onClick = onQuit) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Quit"
)
}
}
onNavigateBack != null -> {
IconButton(onClick = onNavigateBack) {
Icon(

View File

@ -0,0 +1,88 @@
package com.example.llama.ui.scaffold.topbar
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.llama.viewmodel.ModelScreenUiMode
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModelsBrowsingTopBar(
title: String,
onToggleMode: (ModelScreenUiMode) -> Unit,
onNavigateBack: (() -> Unit)? = null,
onMenuOpen: (() -> Unit)? = null,
) {
TopAppBar(
title = { Text(title) },
navigationIcon = {
when {
onNavigateBack != null -> {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
onMenuOpen != null -> {
IconButton(onClick = onMenuOpen) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = "Menu"
)
}
}
}
},
actions = {
ModelManageActionToggle(onToggleManageMode = {
onToggleMode(ModelScreenUiMode.MANAGING)
})
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
@Composable
private fun ModelManageActionToggle(
onToggleManageMode: () -> Unit,
) {
FilledTonalButton(
modifier = Modifier.padding(end = 12.dp),
onClick = onToggleManageMode
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Build,
contentDescription = "Manage models",
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = "Manage",
style = MaterialTheme.typography.bodySmall
)
}
}
}

View File

@ -1,10 +1,7 @@
package com.example.llama.ui.scaffold.topbar
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Memory
@ -15,13 +12,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.example.llama.monitoring.MemoryMetrics
import com.example.llama.monitoring.TemperatureMetrics
@ -92,30 +89,32 @@ private fun MemoryIndicator(
val availableGB = String.format(Locale.getDefault(), "%.1f", memoryUsage.availableGB)
val totalGB = String.format(Locale.getDefault(), "%.1f", memoryUsage.totalGB)
Row(
modifier = Modifier.padding(end = 12.dp).clickable(role = Role.Button) {
OutlinedButton(
modifier = Modifier.padding(end = 8.dp),
onClick = {
onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
message = "Free RAM available: $availableGB GB\nTotal RAM on your device: $totalGB GB",
withDismissAction = true,
))
},
verticalAlignment = Alignment.CenterVertically,
}
) {
Icon(
imageVector = Icons.Default.Memory,
contentDescription = "RAM usage",
tint = when {
memoryUsage.availableGB < 1 -> MaterialTheme.colorScheme.error
memoryUsage.availableGB < 3 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Memory,
contentDescription = "RAM usage",
tint = when {
memoryUsage.availableGB < 1 -> MaterialTheme.colorScheme.error
memoryUsage.availableGB < 3 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = "$availableGB / $totalGB GB",
style = MaterialTheme.typography.bodySmall,
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = "$availableGB / $totalGB GB",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@ -134,36 +133,39 @@ private fun TemperatureIndicator(
}
val warningDismissible = temperatureMetrics.warningLevel == TemperatureWarningLevel.HIGH
Row(
modifier = Modifier.padding(end = 12.dp).clickable(role = Role.Button) {
OutlinedButton(
modifier = Modifier.padding(end = 8.dp),
onClick = {
onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
message = temperatureWarning,
withDismissAction = warningDismissible,
))
},
verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = when (temperatureMetrics.warningLevel) {
TemperatureWarningLevel.HIGH -> Icons.Default.WarningAmber
else -> Icons.Default.Thermostat
},
contentDescription = "Device temperature",
tint = when (temperatureMetrics.warningLevel) {
TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error
TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
)
}
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = when (temperatureMetrics.warningLevel) {
TemperatureWarningLevel.HIGH -> Icons.Default.WarningAmber
else -> Icons.Default.Thermostat
},
contentDescription = "Device temperature",
tint = when (temperatureMetrics.warningLevel) {
TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error
TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = temperatureDisplay,
style = MaterialTheme.typography.bodySmall,
color = when (temperatureMetrics.warningLevel) {
TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error
TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = temperatureDisplay,
style = MaterialTheme.typography.bodySmall,
color = when (temperatureMetrics.warningLevel) {
TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error
TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
)
}
}
}

View File

@ -1,10 +1,7 @@
package com.example.llama.ui.scaffold.topbar
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.SdStorage
@ -12,13 +9,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.example.llama.monitoring.StorageMetrics
import com.example.llama.ui.scaffold.ScaffoldEvent
@ -65,29 +62,31 @@ private fun StorageIndicator(
val usedGb = String.format(Locale.getDefault(), "%.1f", storageMetrics.usedGB)
val availableGb = String.format(Locale.getDefault(), "%.1f", storageMetrics.availableGB)
Row(
modifier = Modifier.padding(end = 8.dp).clickable(role = Role.Button) {
OutlinedButton(
modifier = Modifier.padding(end = 8.dp),
onClick = {
onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
message = "Your models occupy $usedGb GB storage\nRemaining free space available: $availableGb GB",
withDismissAction = true,
))
},
verticalAlignment = Alignment.CenterVertically
}
) {
Icon(
imageVector = Icons.Default.SdStorage,
contentDescription = "Storage",
tint = when {
storageMetrics.availableGB < 5.0f -> MaterialTheme.colorScheme.error
storageMetrics.availableGB < 10.0f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.SdStorage,
contentDescription = "Storage",
tint = when {
storageMetrics.availableGB < 5.0f -> MaterialTheme.colorScheme.error
storageMetrics.availableGB < 10.0f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = "$usedGb / $availableGb GB",
style = MaterialTheme.typography.bodySmall
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = "$usedGb / $availableGb GB",
style = MaterialTheme.typography.bodySmall
)
}
}
}

View File

@ -3,6 +3,8 @@ package com.example.llama.ui.scaffold.topbar
import com.example.llama.monitoring.MemoryMetrics
import com.example.llama.monitoring.StorageMetrics
import com.example.llama.monitoring.TemperatureMetrics
import com.example.llama.ui.scaffold.ScaffoldEvent
import com.example.llama.viewmodel.ModelScreenUiMode
/**
* [TopAppBar] configurations
@ -23,6 +25,19 @@ sealed class TopBarConfig {
override val navigationIcon: NavigationIcon
) : TopBarConfig()
// Model management top bar with a toggle to turn on/off manage mode
data class ModelsBrowsing(
override val title: String,
override val navigationIcon: NavigationIcon,
val onToggleMode: (ModelScreenUiMode) -> Unit,
) : TopBarConfig()
// Model batch-deletion top bar with a toggle to turn on/off manage mode
data class ModelsDeleting(
override val title: String,
override val navigationIcon: NavigationIcon,
) : TopBarConfig()
// Performance monitoring top bar with RAM and optional temperature
data class Performance(
override val title: String,
@ -32,7 +47,7 @@ sealed class TopBarConfig {
) : TopBarConfig()
// Storage management top bar with used & total storage
data class Storage(
data class ModelsManagement(
override val title: String,
override val navigationIcon: NavigationIcon,
val storageMetrics: StorageMetrics?
@ -43,7 +58,8 @@ sealed class TopBarConfig {
* Helper class for navigation icon configuration
*/
sealed class NavigationIcon {
data class Back(val onNavigateBack: () -> Unit) : NavigationIcon()
data class Menu(val onMenuOpen: () -> Unit) : NavigationIcon()
object None : NavigationIcon()
data class Back(val onNavigateBack: () -> Unit) : NavigationIcon()
data class Quit(val onQuit: () -> Unit) : NavigationIcon()
data object None : NavigationIcon()
}

View File

@ -110,7 +110,7 @@ fun ConversationScreen(
// UI states
val lifecycleOwner = LocalLifecycleOwner.current
val coroutineScope = rememberCoroutineScope()
var isModelCardExpanded by remember { mutableStateOf(false) }
var isModelCardExpanded by remember { mutableStateOf(true) }
val listState = rememberLazyListState()
// Track the actual rendered size of the last message bubble
@ -234,7 +234,7 @@ fun ModelCardWithSystemPrompt(
model: ModelInfo,
loadingMetrics: ModelLoadingMetrics,
systemPrompt: String?,
isExpanded: Boolean = false,
isExpanded: Boolean = true,
onExpanded: ((Boolean) -> Unit)? = null,
) = ModelCardCoreExpandable(model, isExpanded, onExpanded) {
Spacer(modifier = Modifier.height(8.dp))

View File

@ -1,315 +0,0 @@
package com.example.llama.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SearchOff
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DockedSearchBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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.unit.dp
import com.example.llama.data.model.ModelInfo
import com.example.llama.ui.components.InfoAction
import com.example.llama.ui.components.InfoView
import com.example.llama.ui.components.ModelCardFullExpandable
import com.example.llama.util.formatFileByteSize
import com.example.llama.viewmodel.ModelSelectionViewModel
import com.example.llama.viewmodel.Preselection.RamWarning
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModelSelectionScreen(
onManageModelsClicked: () -> Unit,
onConfirmSelection: (ModelInfo, RamWarning) -> Unit,
viewModel: ModelSelectionViewModel,
) {
// Data: models
val filteredModels by viewModel.filteredModels.collectAsState()
val preselection by viewModel.preselection.collectAsState()
// Query states
val textFieldState = viewModel.searchFieldState
val isSearchActive by viewModel.isSearchActive.collectAsState()
val searchQuery by remember(textFieldState) {
derivedStateOf { textFieldState.text.toString() }
}
val queryResults by viewModel.queryResults.collectAsState()
// Filter states
val activeFilters by viewModel.activeFilters.collectAsState()
val activeFiltersCount by remember(activeFilters) {
derivedStateOf { activeFilters.count { it.value } }
}
// UI states
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val toggleSearchFocusAndIme: (Boolean) -> Unit = { show ->
if (show) {
focusRequester.requestFocus()
keyboardController?.show()
} else {
focusRequester.freeFocus()
keyboardController?.hide()
}
}
// Handle back button press
BackHandler(preselection != null || isSearchActive) {
if (isSearchActive) {
viewModel.toggleSearchState(false)
} else {
viewModel.onBackPressed()
}
}
LaunchedEffect (isSearchActive) {
if (isSearchActive) {
toggleSearchFocusAndIme(true)
}
}
Box(
modifier = Modifier.fillMaxSize()
) {
if (isSearchActive) {
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()) {
// If no results under current query, show "no results" message
EmptySearchResultsView(
onClearSearch = {
textFieldState.clearText()
toggleSearchFocusAndIme(true)
}
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
) {
items(items = queryResults, key = { it.id }) { model ->
ModelCardFullExpandable(
model = model,
isSelected = if (model == preselection?.modelInfo) true else null,
onSelected = { selected ->
if (selected) {
toggleSearchFocusAndIme(false)
} else {
viewModel.resetPreselection()
toggleSearchFocusAndIme(true)
}
},
isExpanded = model == preselection?.modelInfo,
onExpanded = { expanded ->
viewModel.preselectModel(model, expanded)
toggleSearchFocusAndIme(!expanded)
}
)
}
}
}
}
} else {
if (filteredModels.isEmpty()) {
// Empty model prompt
EmptyModelsView(activeFiltersCount, onManageModelsClicked)
} else {
// Model cards
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(
model = model,
isSelected = if (model == preselection?.modelInfo) true else null,
onSelected = { selected ->
if (!selected) viewModel.resetPreselection()
},
isExpanded = model == preselection?.modelInfo,
onExpanded = { expanded ->
viewModel.preselectModel(model, expanded)
}
)
}
}
}
}
// Show insufficient RAM warning
preselection?.let {
it.ramWarning?.let { warning ->
if (warning.showing) {
RamErrorDialog(
warning,
onDismiss = { viewModel.dismissRamWarning() },
onConfirm = { onConfirmSelection(it.modelInfo, warning) }
)
}
}
}
}
}
@Composable
private fun EmptyModelsView(
activeFiltersCount: Int,
onManageModelsClicked: () -> Unit
) {
val message = when (activeFiltersCount) {
0 -> "Import some models to get started!"
1 -> "No models match the selected filter"
else -> "No models match the selected filters"
}
InfoView(
modifier = Modifier.fillMaxSize(),
title = "No Models Available",
icon = Icons.Default.FolderOpen,
message = message,
action = InfoAction(
label = "Add Models",
icon = Icons.Default.Add,
onAction = onManageModelsClicked
)
)
}
@Composable
private fun EmptySearchResultsView(
onClearSearch: () -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.SearchOff,
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
private fun RamErrorDialog(
ramError: RamWarning,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
val requiredRam = formatFileByteSize(ramError.requiredRam)
val availableRam = formatFileByteSize(ramError.availableRam)
AlertDialog(
text = {
InfoView(
modifier = Modifier.fillMaxWidth(),
title = "Insufficient RAM",
icon = Icons.Default.Warning,
message = "You are trying to run a $requiredRam size model, " +
"but currently there's only $availableRam memory available!",
)
},
containerColor = MaterialTheme.colorScheme.errorContainer,
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
textContentColor = MaterialTheme.colorScheme.onErrorContainer,
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
text = "Proceed",
color = MaterialTheme.colorScheme.error
)
}
}
)
}

View File

@ -0,0 +1,88 @@
package com.example.llama.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.llama.ui.components.InfoAction
import com.example.llama.ui.components.InfoView
import com.example.llama.ui.components.ModelCardFullExpandable
import com.example.llama.viewmodel.ModelsViewModel
@Composable
fun ModelsBrowsingScreen(
onManageModelsClicked: () -> Unit,
viewModel: ModelsViewModel,
) {
// Data: models
val filteredModels by viewModel.filteredModels.collectAsState()
val preselection by viewModel.preselectedModelToRun.collectAsState()
// Filter states
val activeFilters by viewModel.activeFilters.collectAsState()
val activeFiltersCount by remember(activeFilters) {
derivedStateOf { activeFilters.count { it.value } }
}
if (filteredModels.isEmpty()) {
// Empty model prompt
EmptyModelsView(activeFiltersCount, onManageModelsClicked)
} else {
// Model cards
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(
model = model,
isSelected = if (model == preselection?.modelInfo) true else null,
onSelected = { selected ->
if (!selected) viewModel.resetPreselection()
},
isExpanded = model == preselection?.modelInfo,
onExpanded = { expanded ->
viewModel.preselectModel(model, expanded)
}
)
}
}
}
}
@Composable
private fun EmptyModelsView(
activeFiltersCount: Int,
onManageModelsClicked: () -> Unit
) {
val message = when (activeFiltersCount) {
0 -> "Import some models to get started!"
1 -> "No models match the selected filter"
else -> "No models match the selected filters"
}
InfoView(
modifier = Modifier.fillMaxSize(),
title = "No Models Available",
icon = Icons.Default.FolderOpen,
message = message,
action = InfoAction(
label = "Add Models",
icon = Icons.Default.Add,
onAction = onManageModelsClicked
)
)
}

View File

@ -67,7 +67,8 @@ import com.example.llama.viewmodel.ModelManagementState
import com.example.llama.viewmodel.ModelManagementState.Deletion
import com.example.llama.viewmodel.ModelManagementState.Download
import com.example.llama.viewmodel.ModelManagementState.Importation
import com.example.llama.viewmodel.ModelsManagementViewModel
import com.example.llama.viewmodel.ModelScreenUiMode
import com.example.llama.viewmodel.ModelsViewModel
import java.text.SimpleDateFormat
import java.util.Locale
@ -75,16 +76,16 @@ import java.util.Locale
* Screen for managing LLM models (view, download, delete)
*/
@Composable
fun ModelsManagementScreen(
fun ModelsManagementAndDeletingScreen(
isDeleting: Boolean,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
viewModel: ModelsManagementViewModel,
viewModel: ModelsViewModel,
) {
// Data: models
val filteredModels by viewModel.filteredModels.collectAsState()
// Selection state
val isMultiSelectionMode by viewModel.isMultiSelectionMode.collectAsState()
val selectedModels by viewModel.selectedModels.collectAsState()
val selectedModels by viewModel.selectedModelsToDelete.collectAsState()
// Filter state
val activeFilters by viewModel.activeFilters.collectAsState()
@ -96,19 +97,13 @@ fun ModelsManagementScreen(
val managementState by viewModel.managementState.collectAsState()
// UI states
var expandedModels = remember { mutableStateMapOf<String, ModelInfo>() }
val expandedModels = remember { mutableStateMapOf<String, ModelInfo>() }
BackHandler(
enabled = isMultiSelectionMode
|| managementState is Importation.Importing
enabled = managementState is Importation.Importing
|| managementState is Deletion.Deleting
) {
if (isMultiSelectionMode) {
// Exit selection mode if in selection mode
viewModel.toggleSelectionMode(false)
} else {
/* Ignore back press while processing model management requests */
}
/* Ignore back press while processing model management requests */
}
Box(modifier = Modifier.fillMaxSize()) {
@ -134,13 +129,13 @@ fun ModelsManagementScreen(
) {
items(items = filteredModels, key = { it.id }) { model ->
val isSelected =
if (isMultiSelectionMode) selectedModels.contains(model.id) else null
if (isDeleting) selectedModels.contains(model.id) else null
ModelCardFullExpandable(
model = model,
isSelected = isSelected,
onSelected = {
if (isMultiSelectionMode) {
if (isDeleting) {
viewModel.toggleModelSelectionById(model.id)
}
},
@ -285,7 +280,7 @@ fun ModelsManagementScreen(
is Deletion.Success -> {
LaunchedEffect(state) {
viewModel.toggleSelectionMode(false)
viewModel.toggleMode(ModelScreenUiMode.MANAGING)
val count = state.models.size
onScaffoldEvent(
@ -641,6 +636,9 @@ private fun BatchDeleteConfirmationDialog(
}
}
},
containerColor = MaterialTheme.colorScheme.errorContainer,
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
textContentColor = MaterialTheme.colorScheme.onErrorContainer,
confirmButton = {
TextButton(
onClick = onConfirm,

View File

@ -0,0 +1,132 @@
package com.example.llama.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.example.llama.data.model.ModelInfo
import com.example.llama.ui.components.InfoView
import com.example.llama.ui.scaffold.ScaffoldEvent
import com.example.llama.util.formatFileByteSize
import com.example.llama.viewmodel.ModelScreenUiMode
import com.example.llama.viewmodel.ModelsViewModel
import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModelsScreen(
onManageModelsClicked: () -> Unit,
onConfirmSelection: (ModelInfo, RamWarning) -> Unit,
onScaffoldEvent: (ScaffoldEvent) -> Unit,
viewModel: ModelsViewModel,
) {
// Data
val preselection by viewModel.preselectedModelToRun.collectAsState()
// UI states
val currentMode by viewModel.modelScreenUiMode.collectAsState()
// Handle back button press
BackHandler {
when (currentMode) {
ModelScreenUiMode.BROWSING -> {
if (preselection != null) {
viewModel.resetPreselection()
}
}
ModelScreenUiMode.SEARCHING -> {
viewModel.toggleMode(ModelScreenUiMode.BROWSING)
}
ModelScreenUiMode.MANAGING -> {
viewModel.toggleMode(ModelScreenUiMode.BROWSING)
}
ModelScreenUiMode.DELETING -> {
viewModel.toggleAllSelectedModelsToDelete(false)
viewModel.toggleMode(ModelScreenUiMode.MANAGING)
}
}
}
Box(
modifier = Modifier.fillMaxSize()
) {
when (currentMode) {
ModelScreenUiMode.BROWSING ->
ModelsBrowsingScreen(
onManageModelsClicked = { /* TODO-han.yin */ },
viewModel = viewModel
)
ModelScreenUiMode.SEARCHING ->
ModelsSearchingScreen(viewModel = viewModel)
ModelScreenUiMode.MANAGING, ModelScreenUiMode.DELETING ->
ModelsManagementAndDeletingScreen(
isDeleting = currentMode == ModelScreenUiMode.DELETING,
onScaffoldEvent = onScaffoldEvent,
viewModel = viewModel
)
}
// Show insufficient RAM warning
preselection?.let {
it.ramWarning?.let { warning ->
if (warning.showing) {
RamErrorDialog(
warning,
onDismiss = { viewModel.dismissRamWarning() },
onConfirm = { onConfirmSelection(it.modelInfo, warning) }
)
}
}
}
}
}
@Composable
private fun RamErrorDialog(
ramError: RamWarning,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
val requiredRam = formatFileByteSize(ramError.requiredRam)
val availableRam = formatFileByteSize(ramError.availableRam)
AlertDialog(
text = {
InfoView(
modifier = Modifier.fillMaxWidth(),
title = "Insufficient RAM",
icon = Icons.Default.Warning,
message = "You are trying to run a $requiredRam size model, " +
"but currently there's only $availableRam memory available!",
)
},
containerColor = MaterialTheme.colorScheme.errorContainer,
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
textContentColor = MaterialTheme.colorScheme.onErrorContainer,
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
text = "Proceed",
color = MaterialTheme.colorScheme.error
)
}
}
)
}

View File

@ -0,0 +1,186 @@
package com.example.llama.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material.icons.Icons
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.DockedSearchBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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.unit.dp
import com.example.llama.ui.components.ModelCardFullExpandable
import com.example.llama.viewmodel.ModelScreenUiMode
import com.example.llama.viewmodel.ModelsViewModel
@ExperimentalMaterial3Api
@Composable
fun ModelsSearchingScreen(
viewModel: ModelsViewModel,
) {
val preselection by viewModel.preselectedModelToRun.collectAsState()
// Query states
val textFieldState = viewModel.searchFieldState
val searchQuery by remember(textFieldState) {
derivedStateOf { textFieldState.text.toString() }
}
val queryResults by viewModel.queryResults.collectAsState()
// Local UI states
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val toggleSearchFocusAndIme: (Boolean) -> Unit = { show ->
if (show) {
focusRequester.requestFocus()
keyboardController?.show()
} else {
focusRequester.freeFocus()
keyboardController?.hide()
}
}
// LaunchedEffect (isSearchActive) {
// if (isSearchActive) {
// toggleSearchFocusAndIme(true)
// }
// }
val handleExpanded: (Boolean) -> Unit = { expanded ->
viewModel.toggleMode(
if (expanded) ModelScreenUiMode.SEARCHING
else ModelScreenUiMode.BROWSING
)
textFieldState.clearText()
}
Box(modifier = Modifier.fillMaxSize()) {
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 = handleExpanded,
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = { Icon(Icons.Default.MoreVert, contentDescription = null) },
placeholder = { Text("Type to search your models") }
)
},
expanded = true,
onExpandedChange = handleExpanded
) {
if (queryResults.isEmpty()) {
if (searchQuery.isNotBlank()) {
// If no results under current query, show "no results" message
EmptySearchResultsView(
onClearSearch = {
textFieldState.clearText()
toggleSearchFocusAndIme(true)
}
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
) {
items(items = queryResults, key = { it.id }) { model ->
ModelCardFullExpandable(
model = model,
isSelected = if (model == preselection?.modelInfo) true else null,
onSelected = { selected ->
if (selected) {
toggleSearchFocusAndIme(false)
} else {
viewModel.resetPreselection()
toggleSearchFocusAndIme(true)
}
},
isExpanded = model == preselection?.modelInfo,
onExpanded = { expanded ->
viewModel.preselectModel(model, expanded)
toggleSearchFocusAndIme(!expanded)
}
)
}
}
}
}
}
}
@Composable
private fun EmptySearchResultsView(
onClearSearch: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.SearchOff,
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")
}
}
}

View File

@ -1,241 +0,0 @@
package com.example.llama.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.viewModelScope
import com.example.llama.data.model.ModelFilter
import com.example.llama.data.model.ModelInfo
import com.example.llama.data.model.ModelSortOrder
import com.example.llama.data.model.filterBy
import com.example.llama.data.model.queryBy
import com.example.llama.data.model.sortByOrder
import com.example.llama.data.repo.ModelRepository
import com.example.llama.engine.InferenceService
import com.example.llama.monitoring.PerformanceMonitor
import com.example.llama.viewmodel.Preselection.RamWarning
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(FlowPreview::class)
@HiltViewModel
class ModelSelectionViewModel @Inject constructor(
modelRepository: ModelRepository,
private val performanceMonitor: PerformanceMonitor,
private val inferenceService: InferenceService,
) : ViewModel() {
// UI state: search mode
private val _isSearchActive = MutableStateFlow(false)
val isSearchActive = _isSearchActive.asStateFlow()
fun toggleSearchState(active: Boolean) {
_isSearchActive.value = active
if (active) {
resetPreselection()
} else {
searchFieldState.clearText()
}
}
val searchFieldState = TextFieldState()
// UI state: sort menu
private val _sortOrder = MutableStateFlow(ModelSortOrder.LAST_USED)
val sortOrder = _sortOrder.asStateFlow()
fun setSortOrder(order: ModelSortOrder) {
_sortOrder.value = order
}
private val _showSortMenu = MutableStateFlow(false)
val showSortMenu = _showSortMenu.asStateFlow()
fun toggleSortMenu(visible: Boolean) {
_showSortMenu.value = visible
}
// UI state: filter menu
private val _activeFilters = MutableStateFlow<Map<ModelFilter, Boolean>>(
ModelFilter.ALL_FILTERS.associateWith { false }
)
val activeFilters: StateFlow<Map<ModelFilter, Boolean>> = _activeFilters.asStateFlow()
fun toggleFilter(filter: ModelFilter, 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 = _showFilterMenu.asStateFlow()
fun toggleFilterMenu(visible: Boolean) {
_showFilterMenu.value = visible
}
// Data: filtered & sorted models
private val _filteredModels = MutableStateFlow<List<ModelInfo>>(emptyList())
val filteredModels = _filteredModels.asStateFlow()
// Data: queried models
private val _queryResults = MutableStateFlow<List<ModelInfo>>(emptyList())
val queryResults = _queryResults.asStateFlow()
// Data: pre-selected model in expansion mode
private val _preselectedModel = MutableStateFlow<ModelInfo?>(null)
private val _showRamWarning = MutableStateFlow(false)
val preselection = combine(
_preselectedModel,
performanceMonitor.monitorMemoryUsage(),
_showRamWarning,
) { model, memory, show ->
if (model == null) {
null
} else {
if (memory.availableMem >= model.sizeInBytes + RAM_LOAD_MODEL_BUFFER_BYTES) {
Preselection(model, null)
} else {
Preselection(model, RamWarning(model.sizeInBytes, memory.availableMem, show))
}
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
initialValue = null
)
init {
viewModelScope.launch {
combine(
modelRepository.getModels(),
_activeFilters,
_sortOrder,
) { models, filters, sortOrder ->
models.filterBy(filters).sortByOrder(sortOrder)
}.collectLatest {
_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
}
}
}
/**
* Pre-select a model to expand its details and show Run FAB
*/
fun preselectModel(modelInfo: ModelInfo, preselected: Boolean) {
_preselectedModel.value = if (preselected) modelInfo else null
_showRamWarning.value = false
}
/**
* Reset preselected model to none (before navigating away)
*/
fun resetPreselection() {
_preselectedModel.value = null
_showRamWarning.value = false
}
/**
* Select the currently pre-selected model
*
* @return True if RAM enough, otherwise False.
*/
fun selectModel(preselection: Preselection) =
when (preselection.ramWarning?.showing) {
null -> {
inferenceService.setCurrentModel(preselection.modelInfo)
true
}
false -> {
_showRamWarning.value = true
false
}
else -> false
}
/**
* Dismiss the RAM warnings
*/
fun dismissRamWarning() {
_showRamWarning.value = false
}
/**
* Acknowledge RAM warnings and confirm currently pre-selected model
*
* @return True if confirmed, otherwise False.
*/
fun confirmSelectedModel(modelInfo: ModelInfo, ramWarning: RamWarning): Boolean =
if (ramWarning.showing) {
inferenceService.setCurrentModel(modelInfo)
_showRamWarning.value = false
true
} else {
false
}
/**
* Handle back press from both back button and top bar
*/
fun onBackPressed() {
if (_preselectedModel.value != null) {
resetPreselection()
}
}
companion object {
private val TAG = ModelSelectionViewModel::class.java.simpleName
private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
private const val QUERY_DEBOUNCE_TIMEOUT_MS = 500L
private const val RAM_LOAD_MODEL_BUFFER_BYTES = 300 * 1024
}
}
data class Preselection(
val modelInfo: ModelInfo,
val ramWarning: RamWarning?,
) {
data class RamWarning(
val requiredRam: Long,
val availableRam: Long,
val showing: Boolean,
)
}

View File

@ -9,33 +9,43 @@ import android.content.IntentFilter
import android.llama.cpp.gguf.InvalidFileFormatException
import android.net.Uri
import android.util.Log
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.viewModelScope
import com.example.llama.data.model.ModelFilter
import com.example.llama.data.model.ModelInfo
import com.example.llama.data.model.ModelSortOrder
import com.example.llama.data.model.filterBy
import com.example.llama.data.model.queryBy
import com.example.llama.data.model.sortByOrder
import com.example.llama.data.source.remote.HuggingFaceDownloadInfo
import com.example.llama.data.source.remote.HuggingFaceModel
import com.example.llama.data.repo.InsufficientStorageException
import com.example.llama.data.repo.ModelRepository
import com.example.llama.data.source.remote.HuggingFaceDownloadInfo
import com.example.llama.data.source.remote.HuggingFaceModel
import com.example.llama.engine.InferenceService
import com.example.llama.monitoring.PerformanceMonitor
import com.example.llama.util.formatFileByteSize
import com.example.llama.util.getFileNameFromUri
import com.example.llama.util.getFileSizeFromUri
import com.example.llama.viewmodel.ModelManagementState.Deletion
import com.example.llama.viewmodel.ModelManagementState.Download
import com.example.llama.viewmodel.ModelManagementState.Importation
import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.FileNotFoundException
@ -43,70 +53,81 @@ import java.io.IOException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
@OptIn(FlowPreview::class)
@HiltViewModel
class ModelsManagementViewModel @Inject constructor(
class ModelsViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val modelRepository: ModelRepository
private val modelRepository: ModelRepository,
private val performanceMonitor: PerformanceMonitor,
private val inferenceService: InferenceService,
) : ViewModel() {
// Data: models
private val _filteredModels = MutableStateFlow<List<ModelInfo>>(emptyList())
val filteredModels: StateFlow<List<ModelInfo>> = _filteredModels.asStateFlow()
// UI state: model management mode
private val _modelScreenUiMode = MutableStateFlow(ModelScreenUiMode.BROWSING)
val modelScreenUiMode = _modelScreenUiMode.asStateFlow()
// UI state: multi-selection mode
private val _isMultiSelectionMode = MutableStateFlow(false)
val isMultiSelectionMode: StateFlow<Boolean> = _isMultiSelectionMode.asStateFlow()
fun toggleSelectionMode(enabled: Boolean) {
_isMultiSelectionMode.value = enabled
if (!enabled) {
toggleAllSelection(selectAll = false)
}
}
// UI state: models selected in multi-selection
private val _selectedModels = MutableStateFlow<Map<String, ModelInfo>>(emptyMap())
val selectedModels: StateFlow<Map<String, ModelInfo>> = _selectedModels.asStateFlow()
fun toggleModelSelectionById(modelId: String) {
val current = _selectedModels.value.toMutableMap()
val model = _filteredModels.value.find { it.id == modelId }
if (model != null) {
if (current.containsKey(modelId)) {
current.remove(modelId)
} else {
current[modelId] = model
fun toggleMode(newMode: ModelScreenUiMode): Boolean {
val oldMode = _modelScreenUiMode.value
when (oldMode) {
ModelScreenUiMode.BROWSING -> {
when (newMode) {
ModelScreenUiMode.SEARCHING -> {
resetPreselection()
}
ModelScreenUiMode.MANAGING -> {
resetPreselection()
}
ModelScreenUiMode.DELETING -> { return false }
else -> { /* No-op */ }
}
}
ModelScreenUiMode.SEARCHING -> {
when (newMode) {
ModelScreenUiMode.BROWSING -> {
searchFieldState.clearText()
}
else -> { return false }
}
}
ModelScreenUiMode.MANAGING -> {
when (newMode) {
ModelScreenUiMode.SEARCHING -> { return false }
else -> { /* No-op */ }
}
}
ModelScreenUiMode.DELETING -> {
when (newMode) {
ModelScreenUiMode.BROWSING, ModelScreenUiMode.SEARCHING -> { return false }
else -> { /* No-op */ }
}
}
_selectedModels.value = current
}
_modelScreenUiMode.value = newMode
return true
}
fun toggleAllSelection(selectAll: Boolean) {
if (selectAll) {
_selectedModels.value = _filteredModels.value.associateBy { it.id }
} else {
_selectedModels.value = emptyMap()
}
}
// UI state: search mode
val searchFieldState = TextFieldState()
// UI state: sort menu
private val _sortOrder = MutableStateFlow(ModelSortOrder.NAME_ASC)
val sortOrder: StateFlow<ModelSortOrder> = _sortOrder.asStateFlow()
private val _sortOrder = MutableStateFlow(ModelSortOrder.LAST_USED)
val sortOrder = _sortOrder.asStateFlow()
fun setSortOrder(order: ModelSortOrder) {
_sortOrder.value = order
}
private val _showSortMenu = MutableStateFlow(false)
val showSortMenu: StateFlow<Boolean> = _showSortMenu.asStateFlow()
val showSortMenu = _showSortMenu.asStateFlow()
fun toggleSortMenu(show: Boolean) {
_showSortMenu.value = show
fun toggleSortMenu(visible: Boolean) {
_showSortMenu.value = visible
}
// UI state: filters
// UI state: filter menu
private val _activeFilters = MutableStateFlow<Map<ModelFilter, Boolean>>(
ModelFilter.ALL_FILTERS.associateWith { false }
)
@ -127,12 +148,69 @@ class ModelsManagementViewModel @Inject constructor(
}
private val _showFilterMenu = MutableStateFlow(false)
val showFilterMenu: StateFlow<Boolean> = _showFilterMenu.asStateFlow()
val showFilterMenu = _showFilterMenu.asStateFlow()
fun toggleFilterMenu(visible: Boolean) {
_showFilterMenu.value = visible
}
// Data: filtered & sorted models
private val _filteredModels = MutableStateFlow<List<ModelInfo>>(emptyList())
val filteredModels = _filteredModels.asStateFlow()
// Data: queried models
private val _queryResults = MutableStateFlow<List<ModelInfo>>(emptyList())
val queryResults = _queryResults.asStateFlow()
// Data: pre-selected model in expansion mode
private val _preselectedModelToRun = MutableStateFlow<ModelInfo?>(null)
private val _showRamWarning = MutableStateFlow(false)
val preselectedModelToRun = combine(
_preselectedModelToRun,
performanceMonitor.monitorMemoryUsage(),
_showRamWarning,
) { model, memory, show ->
if (model == null) {
null
} else {
if (memory.availableMem >= model.sizeInBytes + RAM_LOAD_MODEL_BUFFER_BYTES) {
PreselectedModelToRun(model, null)
} else {
PreselectedModelToRun(model, RamWarning(model.sizeInBytes, memory.availableMem, show))
}
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
initialValue = null
)
// UI state: models selected in deleting mode
private val _selectedModelsToDelete = MutableStateFlow<Map<String, ModelInfo>>(emptyMap())
val selectedModelsToDelete: StateFlow<Map<String, ModelInfo>> = _selectedModelsToDelete.asStateFlow()
fun toggleModelSelectionById(modelId: String) {
val current = _selectedModelsToDelete.value.toMutableMap()
val model = _filteredModels.value.find { it.id == modelId }
if (model != null) {
if (current.containsKey(modelId)) {
current.remove(modelId)
} else {
current[modelId] = model
}
_selectedModelsToDelete.value = current
}
}
fun toggleAllSelectedModelsToDelete(selectAll: Boolean) {
if (selectAll) {
_selectedModelsToDelete.value = _filteredModels.value.associateBy { it.id }
} else {
_selectedModelsToDelete.value = emptyMap()
}
}
// UI state: import menu
private val _showImportModelMenu = MutableStateFlow(false)
val showImportModelMenu: StateFlow<Boolean> = _showImportModelMenu.asStateFlow()
@ -156,6 +234,17 @@ class ModelsManagementViewModel @Inject constructor(
}
}
// Internal state
private val _managementState = MutableStateFlow<ModelManagementState>(ModelManagementState.Idle)
val managementState: StateFlow<ModelManagementState> = _managementState.asStateFlow()
fun resetManagementState() {
huggingFaceQueryJob?.let {
if (it.isActive) { it.cancel() }
}
_managementState.value = ModelManagementState.Idle
}
init {
viewModelScope.launch {
combine(
@ -169,21 +258,83 @@ class ModelsManagementViewModel @Inject constructor(
}
}
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
}
}
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
context.registerReceiver(downloadReceiver, filter, RECEIVER_EXPORTED)
}
// Internal state
private val _managementState = MutableStateFlow<ModelManagementState>(ModelManagementState.Idle)
val managementState: StateFlow<ModelManagementState> = _managementState.asStateFlow()
fun resetManagementState() {
huggingFaceQueryJob?.let {
if (it.isActive) { it.cancel() }
}
_managementState.value = ModelManagementState.Idle
/**
* Pre-select a model to expand its details and show Run FAB
*/
fun preselectModel(modelInfo: ModelInfo, preselected: Boolean) {
_preselectedModelToRun.value = if (preselected) modelInfo else null
_showRamWarning.value = false
}
/**
* Reset preselected model to none (before navigating away)
*/
fun resetPreselection() {
_preselectedModelToRun.value = null
_showRamWarning.value = false
}
/**
* Select the currently pre-selected model
*
* @return True if RAM enough, otherwise False.
*/
fun selectModel(preselectedModelToRun: PreselectedModelToRun) =
when (preselectedModelToRun.ramWarning?.showing) {
null -> {
inferenceService.setCurrentModel(preselectedModelToRun.modelInfo)
true
}
false -> {
_showRamWarning.value = true
false
}
else -> false
}
/**
* Dismiss the RAM warnings
*/
fun dismissRamWarning() {
_showRamWarning.value = false
}
/**
* Acknowledge RAM warnings and confirm currently pre-selected model
*
* @return True if confirmed, otherwise False.
*/
fun confirmSelectedModel(modelInfo: ModelInfo, ramWarning: RamWarning): Boolean =
if (ramWarning.showing) {
inferenceService.setCurrentModel(modelInfo)
_showRamWarning.value = false
resetPreselection()
true
} else {
false
}
/**
* First show confirmation instead of starting import local file immediately
*/
@ -199,6 +350,7 @@ class ModelsManagementViewModel @Inject constructor(
}
}
/**
* Import a local model file from device storage while updating UI states with realtime progress
*/
@ -359,9 +511,10 @@ class ModelsManagementViewModel @Inject constructor(
_managementState.value = Deletion.Deleting(deleted.toFloat() / total, models)
}
_managementState.value = Deletion.Success(models.values.toList())
toggleAllSelectedModelsToDelete(false)
// Reset state after a delay
delay(SUCCESS_RESET_TIMEOUT_MS)
delay(DELETE_SUCCESS_RESET_TIMEOUT_MS)
_managementState.value = ModelManagementState.Idle
} catch (e: Exception) {
_managementState.value = Deletion.Error(
@ -371,12 +524,35 @@ class ModelsManagementViewModel @Inject constructor(
}
companion object {
private val TAG = ModelsManagementViewModel::class.java.simpleName
private val TAG = ModelsViewModel::class.java.simpleName
private const val SUCCESS_RESET_TIMEOUT_MS = 1000L
private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
private const val QUERY_DEBOUNCE_TIMEOUT_MS = 500L
private const val DELETE_SUCCESS_RESET_TIMEOUT_MS = 1000L
private const val RAM_LOAD_MODEL_BUFFER_BYTES = 300 * 1024
}
}
enum class ModelScreenUiMode {
BROWSING,
SEARCHING,
MANAGING,
DELETING
}
data class PreselectedModelToRun(
val modelInfo: ModelInfo,
val ramWarning: RamWarning?,
) {
data class RamWarning(
val requiredRam: Long,
val availableRam: Long,
val showing: Boolean,
)
}
sealed class ModelManagementState {
object Idle : ModelManagementState()