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.BenchmarkScreen
import com.example.llama.ui.screens.ConversationScreen import com.example.llama.ui.screens.ConversationScreen
import com.example.llama.ui.screens.ModelLoadingScreen import com.example.llama.ui.screens.ModelLoadingScreen
import com.example.llama.ui.screens.ModelSelectionScreen import com.example.llama.ui.screens.ModelsScreen
import com.example.llama.ui.screens.ModelsManagementScreen
import com.example.llama.ui.screens.SettingsGeneralScreen import com.example.llama.ui.screens.SettingsGeneralScreen
import com.example.llama.ui.theme.LlamaTheme import com.example.llama.ui.theme.LlamaTheme
import com.example.llama.viewmodel.BenchmarkViewModel import com.example.llama.viewmodel.BenchmarkViewModel
import com.example.llama.viewmodel.ConversationViewModel import com.example.llama.viewmodel.ConversationViewModel
import com.example.llama.viewmodel.MainViewModel import com.example.llama.viewmodel.MainViewModel
import com.example.llama.viewmodel.ModelLoadingViewModel import com.example.llama.viewmodel.ModelLoadingViewModel
import com.example.llama.viewmodel.ModelSelectionViewModel import com.example.llama.viewmodel.ModelsViewModel
import com.example.llama.viewmodel.ModelsManagementViewModel
import com.example.llama.viewmodel.SettingsViewModel import com.example.llama.viewmodel.SettingsViewModel
import com.example.llama.viewmodel.ModelScreenUiMode
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -85,11 +84,11 @@ class MainActivity : ComponentActivity() {
fun AppContent( fun AppContent(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
mainViewModel: MainViewModel = hiltViewModel(), mainViewModel: MainViewModel = hiltViewModel(),
modelSelectionViewModel: ModelSelectionViewModel = hiltViewModel(), modelsViewModel: ModelsViewModel = hiltViewModel(),
modelLoadingViewModel: ModelLoadingViewModel = hiltViewModel(), modelLoadingViewModel: ModelLoadingViewModel = hiltViewModel(),
benchmarkViewModel: BenchmarkViewModel = hiltViewModel(), benchmarkViewModel: BenchmarkViewModel = hiltViewModel(),
conversationViewModel: ConversationViewModel = hiltViewModel(), conversationViewModel: ConversationViewModel = hiltViewModel(),
modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(), // modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@ -97,6 +96,9 @@ fun AppContent(
// Inference engine state // Inference engine state
val engineState by mainViewModel.engineState.collectAsState() val engineState by mainViewModel.engineState.collectAsState()
// Model state
val modelScreenUiMode by modelsViewModel.modelScreenUiMode.collectAsState()
// Metric states for scaffolds // Metric states for scaffolds
val isMonitoringEnabled by settingsViewModel.isMonitoringEnabled.collectAsState() val isMonitoringEnabled by settingsViewModel.isMonitoringEnabled.collectAsState()
val memoryUsage by settingsViewModel.memoryUsage.collectAsState() val memoryUsage by settingsViewModel.memoryUsage.collectAsState()
@ -187,60 +189,157 @@ fun AppContent(
// Create scaffold's top & bottom bar configs based on current route // Create scaffold's top & bottom bar configs based on current route
val scaffoldConfig = when { val scaffoldConfig = when {
// Model selection screen // Model selection screen
currentRoute == AppDestinations.MODEL_SELECTION_ROUTE -> { currentRoute == AppDestinations.MODELS_ROUTE -> {
// Collect states for bottom bar // Collect states for bottom bar
val isSearchActive by modelSelectionViewModel.isSearchActive.collectAsState() val sortOrder by modelsViewModel.sortOrder.collectAsState()
val sortOrder by modelSelectionViewModel.sortOrder.collectAsState() val showSortMenu by modelsViewModel.showSortMenu.collectAsState()
val showSortMenu by modelSelectionViewModel.showSortMenu.collectAsState() val activeFilters by modelsViewModel.activeFilters.collectAsState()
val activeFilters by modelSelectionViewModel.activeFilters.collectAsState() val showFilterMenu by modelsViewModel.showFilterMenu.collectAsState()
val showFilterMenu by modelSelectionViewModel.showFilterMenu.collectAsState() val preselection by modelsViewModel.preselectedModelToRun.collectAsState()
val preselection by modelSelectionViewModel.preselection.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( ScaffoldConfig(
topBarConfig = topBarConfig =
if (isSearchActive) TopBarConfig.None() when (modelScreenUiMode) {
else TopBarConfig.Default( ModelScreenUiMode.BROWSING ->
title = "Pick your model", TopBarConfig.ModelsBrowsing(
navigationIcon = NavigationIcon.Menu { title = "Installed models",
modelSelectionViewModel.resetPreselection() navigationIcon = NavigationIcon.Menu {
openDrawer() modelsViewModel.resetPreselection()
} openDrawer()
), },
bottomBarConfig = BottomBarConfig.ModelSelection( onToggleMode = modelsViewModel::toggleMode,
search = BottomBarConfig.ModelSelection.SearchConfig( )
isActive = isSearchActive, ModelScreenUiMode.SEARCHING ->
onToggleSearch = modelSelectionViewModel::toggleSearchState, TopBarConfig.None()
textFieldState = modelSelectionViewModel.searchFieldState, ModelScreenUiMode.MANAGING ->
onSearch = { /* No-op for now */ } TopBarConfig.ModelsManagement(
), title = "Managing models",
sorting = BottomBarConfig.ModelSelection.SortingConfig( navigationIcon = NavigationIcon.Back {
currentOrder = sortOrder, modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
isMenuVisible = showSortMenu, },
toggleMenu = modelSelectionViewModel::toggleSortMenu, storageMetrics = if (isMonitoringEnabled) storageMetrics else null,
selectOrder = { )
modelSelectionViewModel.setSortOrder(it) ModelScreenUiMode.DELETING ->
modelSelectionViewModel.toggleSortMenu(false) TopBarConfig.ModelsDeleting(
} title = "Deleting models",
), navigationIcon = NavigationIcon.Quit {
filtering = BottomBarConfig.ModelSelection.FilteringConfig( modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
isActive = activeFilters.any { it.value }, },
filters = activeFilters, )
onToggleFilter = modelSelectionViewModel::toggleFilter, },
onClearFilters = modelSelectionViewModel::clearFilters, bottomBarConfig =
isMenuVisible = showFilterMenu, when (modelScreenUiMode) {
toggleMenu = modelSelectionViewModel::toggleFilterMenu ModelScreenUiMode.BROWSING ->
), BottomBarConfig.Models.Browsing(
runAction = BottomBarConfig.ModelSelection.RunActionConfig( onToggleSearching = {
preselection = preselection, modelsViewModel.toggleMode(ModelScreenUiMode.SEARCHING)
onClickRun = { },
if (modelSelectionViewModel.selectModel(it)) { sorting = BottomBarConfig.Models.Browsing.SortingConfig(
modelSelectionViewModel.toggleSearchState(false) currentOrder = sortOrder,
modelSelectionViewModel.resetPreselection() isMenuVisible = showSortMenu,
navigationActions.navigateToModelLoading() 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 // Fallback for empty screen or unknown routes
else -> ScaffoldConfig( else -> ScaffoldConfig(
topBarConfig = TopBarConfig.Default(title = "", navigationIcon = NavigationIcon.None) topBarConfig = TopBarConfig.Default(title = "", navigationIcon = NavigationIcon.None)
@ -419,22 +449,22 @@ fun AppContent(
// AnimatedNavHost inside the scaffold content // AnimatedNavHost inside the scaffold content
AnimatedNavHost( AnimatedNavHost(
navController = navController, navController = navController,
startDestination = AppDestinations.MODEL_SELECTION_ROUTE, startDestination = AppDestinations.MODELS_ROUTE,
modifier = Modifier.padding(paddingValues) modifier = Modifier.padding(paddingValues)
) { ) {
// Model Selection Screen // Model Selection Screen
composable(AppDestinations.MODEL_SELECTION_ROUTE) { composable(AppDestinations.MODELS_ROUTE) {
ModelSelectionScreen( ModelsScreen(
onManageModelsClicked = { onManageModelsClicked = {
navigationActions.navigateToModelsManagement() // TODO-han.yin: remove this after implementing onboarding flow
}, },
onConfirmSelection = { modelInfo, ramWarning -> onConfirmSelection = { modelInfo, ramWarning ->
if (modelSelectionViewModel.confirmSelectedModel(modelInfo, ramWarning)) { if (modelsViewModel.confirmSelectedModel(modelInfo, ramWarning)) {
navigationActions.navigateToModelLoading() 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 // General Settings Screen
composable(AppDestinations.SETTINGS_GENERAL_ROUTE) { composable(AppDestinations.SETTINGS_GENERAL_ROUTE) {
SettingsGeneralScreen( SettingsGeneralScreen(

View File

@ -8,7 +8,7 @@ import com.example.llama.engine.ModelLoadingMetrics
*/ */
object AppDestinations { object AppDestinations {
// Primary navigation destinations // Primary navigation destinations
const val MODEL_SELECTION_ROUTE = "model_selection" const val MODELS_ROUTE = "models"
const val MODEL_LOADING_ROUTE = "model_loading" const val MODEL_LOADING_ROUTE = "model_loading"
const val CONVERSATION_ROUTE = "conversation" const val CONVERSATION_ROUTE = "conversation"
@ -19,7 +19,6 @@ object AppDestinations {
// Settings destinations // Settings destinations
const val SETTINGS_GENERAL_ROUTE = "settings_general" 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) { class NavigationActions(private val navController: NavController) {
fun navigateToModelSelection() { fun navigateToModelSelection() {
navController.navigate(AppDestinations.MODEL_SELECTION_ROUTE) { navController.navigate(AppDestinations.MODELS_ROUTE) {
// Clear back stack to start fresh // 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) navController.navigate(AppDestinations.SETTINGS_GENERAL_ROUTE)
} }
fun navigateToModelsManagement() {
navController.navigate(AppDestinations.MODELS_MANAGEMENT_ROUTE)
}
fun navigateUp() { fun navigateUp() {
navController.navigateUp() navController.navigateUp()
} }

View File

@ -32,7 +32,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -149,7 +148,6 @@ fun ModelCardCoreExpandable(
* @param isExpanded Whether additional details is expanded or shrunk * @param isExpanded Whether additional details is expanded or shrunk
* @param onExpanded Action to perform when the card is expanded or shrunk * @param onExpanded Action to perform when the card is expanded or shrunk
*/ */
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun ModelCardFullExpandable( fun ModelCardFullExpandable(
model: ModelInfo, model: ModelInfo,
@ -158,10 +156,6 @@ fun ModelCardFullExpandable(
isExpanded: Boolean = false, isExpanded: Boolean = false,
onExpanded: ((Boolean) -> Unit)? = null, onExpanded: ((Boolean) -> Unit)? = null,
) { ) {
LaunchedEffect(model) {
android.util.Log.w("JOJO", model.languages.toString())
}
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Card( Card(
modifier = Modifier 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.BenchmarkBottomBar
import com.example.llama.ui.scaffold.bottombar.BottomBarConfig import com.example.llama.ui.scaffold.bottombar.BottomBarConfig
import com.example.llama.ui.scaffold.bottombar.ConversationBottomBar 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.ModelsManagementBottomBar
import com.example.llama.ui.scaffold.bottombar.ModelsSearchingBottomBar
import com.example.llama.ui.scaffold.topbar.DefaultTopBar 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.NavigationIcon
import com.example.llama.ui.scaffold.topbar.PerformanceTopBar import com.example.llama.ui.scaffold.topbar.PerformanceTopBar
import com.example.llama.ui.scaffold.topbar.StorageTopBar import com.example.llama.ui.scaffold.topbar.StorageTopBar
@ -65,6 +68,25 @@ fun AppScaffold(
onMenuOpen = topBarconfig.navigationIcon.menuAction, 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( is TopBarConfig.Performance -> PerformanceTopBar(
title = topBarconfig.title, title = topBarconfig.title,
memoryMetrics = topBarconfig.memoryMetrics, memoryMetrics = topBarconfig.memoryMetrics,
@ -73,13 +95,6 @@ fun AppScaffold(
onNavigateBack = topBarconfig.navigationIcon.backAction, onNavigateBack = topBarconfig.navigationIcon.backAction,
onMenuOpen = topBarconfig.navigationIcon.menuAction, 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) { when (val config = bottomBarConfig) {
is BottomBarConfig.None -> { /* No bottom bar */ } is BottomBarConfig.None -> { /* No bottom bar */ }
is BottomBarConfig.ModelSelection -> { is BottomBarConfig.Models.Browsing -> {
ModelSelectionBottomBar( ModelsBrowsingBottomBar(
search = config.search, onToggleSearching = config.onToggleSearching,
sorting = config.sorting, sortingConfig = config.sorting,
filtering = config.filtering, filteringConfig = config.filtering,
runAction = config.runAction runActionConfig = config.runAction
) )
} }
is BottomBarConfig.ModelsManagement -> { is BottomBarConfig.Models.Searching -> {
ModelsManagementBottomBar( ModelsSearchingBottomBar(
sorting = config.sorting, textFieldState = config.textFieldState,
filtering = config.filtering, onQuitSearching = config.onQuitSearching,
selection = config.selection, onSearch = config.onSearch,
importing = config.importing, 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 -> { is BottomBarConfig.Benchmark -> {
BenchmarkBottomBar( BenchmarkBottomBar(
showShareFab = config.showShareFab, showShareFab = config.showShareFab,
@ -141,8 +169,11 @@ fun AppScaffold(
} }
// Helper functions to obtain navigation actions if exist // Helper functions to obtain navigation actions if exist
private val NavigationIcon.menuAction: (() -> Unit)?
get() = (this as? NavigationIcon.Menu)?.onMenuOpen
private val NavigationIcon.backAction: (() -> Unit)? private val NavigationIcon.backAction: (() -> Unit)?
get() = (this as? NavigationIcon.Back)?.onNavigateBack get() = (this as? NavigationIcon.Back)?.onNavigateBack
private val NavigationIcon.menuAction: (() -> Unit)? private val NavigationIcon.quitAction: (() -> Unit)?
get() = (this as? NavigationIcon.Menu)?.onMenuOpen get() = (this as? NavigationIcon.Quit)?.onQuit

View File

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

View File

@ -64,10 +64,7 @@ fun BenchmarkBottomBar(
enter = scaleIn() + fadeIn(), enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut() exit = scaleOut() + fadeOut()
) { ) {
FloatingActionButton( FloatingActionButton(onClick = onShare) {
onClick = onShare,
containerColor = MaterialTheme.colorScheme.primary
) {
Icon( Icon(
imageVector = Icons.Default.Share, imageVector = Icons.Default.Share,
contentDescription = "Share the benchmark results" 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.ModelFilter
import com.example.llama.data.model.ModelInfo import com.example.llama.data.model.ModelInfo
import com.example.llama.data.model.ModelSortOrder import com.example.llama.data.model.ModelSortOrder
import com.example.llama.viewmodel.Preselection import com.example.llama.viewmodel.PreselectedModelToRun
/** /**
* [BottomAppBar] configurations * [BottomAppBar] configurations
@ -13,76 +13,78 @@ sealed class BottomBarConfig {
object None : BottomBarConfig() object None : BottomBarConfig()
data class ModelSelection( sealed class Models : BottomBarConfig() {
val search: SearchConfig,
val sorting: SortingConfig, data class Browsing(
val filtering: FilteringConfig, val onToggleSearching: () -> Unit,
val runAction: RunActionConfig val sorting: SortingConfig,
) : BottomBarConfig() { val filtering: FilteringConfig,
data class SearchConfig( val runAction: RunActionConfig,
val isActive: Boolean, ) : BottomBarConfig() {
val onToggleSearch: (Boolean) -> Unit, data class SortingConfig(
val currentOrder: ModelSortOrder,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val selectOrder: (ModelSortOrder) -> Unit
)
data class FilteringConfig(
val isActive: Boolean,
val filters: Map<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit
)
}
data class Searching(
val textFieldState: TextFieldState, val textFieldState: TextFieldState,
val onQuitSearching: () -> Unit,
val onSearch: (String) -> Unit, val onSearch: (String) -> Unit,
) val runAction: RunActionConfig,
) : BottomBarConfig()
data class SortingConfig( data class Management(
val currentOrder: ModelSortOrder, val sorting: SortingConfig,
val isMenuVisible: Boolean, val filtering: FilteringConfig,
val toggleMenu: (Boolean) -> Unit, val importing: ImportConfig,
val selectOrder: (ModelSortOrder) -> Unit val onToggleDeleting: () -> Unit,
) ) : BottomBarConfig() {
data class SortingConfig(
val currentOrder: ModelSortOrder,
val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit,
val selectOrder: (ModelSortOrder) -> Unit
)
data class FilteringConfig( data class FilteringConfig(
val isActive: Boolean, val isActive: Boolean,
val filters: Map<ModelFilter, Boolean>, val filters: Map<ModelFilter, Boolean>,
val onToggleFilter: (ModelFilter, Boolean) -> Unit, val onToggleFilter: (ModelFilter, Boolean) -> Unit,
val onClearFilters: () -> Unit, val onClearFilters: () -> Unit,
val isMenuVisible: Boolean, val isMenuVisible: Boolean,
val toggleMenu: (Boolean) -> Unit val toggleMenu: (Boolean) -> Unit
) )
data class RunActionConfig( data class ImportConfig(
val preselection: Preselection?, val isMenuVisible: Boolean,
val onClickRun: (Preselection) -> Unit, val toggleMenu: (Boolean) -> Unit,
) val importFromLocal: () -> Unit,
} val importFromHuggingFace: () -> Unit
)
}
data class ModelsManagement( data class Deleting(
val sorting: SortingConfig, val onQuitDeleting: () -> Unit,
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,
val selectedModels: Map<String, ModelInfo>, val selectedModels: Map<String, ModelInfo>,
val toggleAllSelection: (Boolean) -> Unit, val toggleAllSelection: (Boolean) -> Unit,
val deleteSelected: () -> Unit val deleteSelected: () -> Unit
) ) : BottomBarConfig()
data class ImportConfig( data class RunActionConfig(
val isMenuVisible: Boolean, val preselectedModelToRun: PreselectedModelToRun?,
val toggleMenu: (Boolean) -> Unit, val onClickRun: (PreselectedModelToRun) -> Unit,
val importFromLocal: () -> Unit,
val importFromHuggingFace: () -> 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.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterAlt import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.outlined.DeleteSweep import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.FilterAlt import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.FilterAltOff import androidx.compose.material.icons.outlined.FilterAltOff
@ -35,176 +31,138 @@ import com.example.llama.data.model.ModelSortOrder
@Composable @Composable
fun ModelsManagementBottomBar( fun ModelsManagementBottomBar(
sorting: BottomBarConfig.ModelsManagement.SortingConfig, sortingConfig: BottomBarConfig.Models.Management.SortingConfig,
filtering: BottomBarConfig.ModelsManagement.FilteringConfig, filteringConfig: BottomBarConfig.Models.Management.FilteringConfig,
selection: BottomBarConfig.ModelsManagement.SelectionConfig, importingConfig: BottomBarConfig.Models.Management.ImportConfig,
importing: BottomBarConfig.ModelsManagement.ImportConfig onToggleDeleting: () -> Unit,
) { ) {
BottomAppBar( BottomAppBar(
actions = { actions = {
if (selection.isActive) { // Batch-deletion action
/* Multi-selection mode actions */ IconButton(onClick = onToggleDeleting) {
IconButton( Icon(
onClick = selection.deleteSelected, imageVector = Icons.Outlined.DeleteSweep,
enabled = selection.selectedModels.isNotEmpty() contentDescription = "Delete models"
) { )
Icon( }
imageVector = Icons.Default.Delete,
contentDescription = "Delete selected",
tint = if (selection.selectedModels.isNotEmpty())
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
IconButton(onClick = { selection.toggleAllSelection(false) }) { // Sorting action
Icon( IconButton(onClick = { sortingConfig.toggleMenu(true) }) {
imageVector = Icons.Default.ClearAll, Icon(
contentDescription = "Deselect all" imageVector = Icons.AutoMirrored.Filled.Sort,
) contentDescription = "Sort models"
} )
}
IconButton(onClick = { selection.toggleAllSelection(true) }) { // Sorting dropdown menu
Icon( DropdownMenu(
imageVector = Icons.Default.SelectAll, expanded = sortingConfig.isMenuVisible,
contentDescription = "Select all" onDismissRequest = { sortingConfig.toggleMenu(false) }
) ) {
} val sortOptions = listOf(
} else { Triple(
/* Default mode actions */ ModelSortOrder.NAME_ASC,
"Name (A-Z)",
// Multi-selection action "Sort by name in ascending order"
IconButton(onClick = { selection.toggleMode(true) }) { ),
Icon( Triple(
imageVector = Icons.Outlined.DeleteSweep, ModelSortOrder.NAME_DESC,
contentDescription = "Delete models" "Name (Z-A)",
) "Sort by name in descending order"
} ),
Triple(
// Sorting action ModelSortOrder.SIZE_ASC,
IconButton(onClick = { sorting.toggleMenu(true) }) { "Size (Smallest first)",
Icon( "Sort by size in ascending order"
imageVector = Icons.AutoMirrored.Filled.Sort, ),
contentDescription = "Sort models" Triple(
) ModelSortOrder.SIZE_DESC,
} "Size (Largest first)",
"Sort by size in descending order"
// Sorting dropdown menu ),
DropdownMenu( Triple(ModelSortOrder.LAST_USED, "Last used", "Sort by last used")
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()
sortOptions.forEach { (order, label, description) ->
DropdownMenuItem( 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 = { leadingIcon = {
Icon( Checkbox(
imageVector = Icons.Outlined.FilterAltOff, checked = isEnabled,
contentDescription = "Clear all filters" onCheckedChange = null
) )
}, },
onClick = { onClick = { filteringConfig.onToggleFilter(filter, !isEnabled) }
filtering.onClearFilters()
filtering.toggleMenu(false)
}
) )
} }
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clear filters") },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.FilterAltOff,
contentDescription = "Clear all filters"
)
},
onClick = {
filteringConfig.onClearFilters()
filteringConfig.toggleMenu(false)
}
)
} }
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = { importingConfig.toggleMenu(true) },
if (selection.isActive) selection.toggleMode(false) else importing.toggleMenu(
true
)
},
containerColor = MaterialTheme.colorScheme.primaryContainer
) { ) {
Icon( Icon(
imageVector = if (selection.isActive) Icons.Default.Close else Icons.Default.Add, imageVector = Icons.Default.Add,
contentDescription = if (selection.isActive) "Exit selection mode" else "Add model" contentDescription = "Add model"
) )
} }
// Add model dropdown menu // Add model dropdown menu
DropdownMenu( DropdownMenu(
expanded = importing.isMenuVisible, expanded = importingConfig.isMenuVisible,
onDismissRequest = { importing.toggleMenu(false) } onDismissRequest = { importingConfig.toggleMenu(false) }
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Import local model") }, text = { Text("Import local model") },
@ -214,7 +172,7 @@ fun ModelsManagementBottomBar(
contentDescription = "Import a local model on the device" contentDescription = "Import a local model on the device"
) )
}, },
onClick = importing.importFromLocal onClick = importingConfig.importFromLocal
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Download from HuggingFace") }, text = { Text("Download from HuggingFace") },
@ -226,7 +184,7 @@ fun ModelsManagementBottomBar(
tint = Color.Unspecified, 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.material.icons.filled.Menu
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -17,12 +18,22 @@ import androidx.compose.runtime.Composable
fun DefaultTopBar( fun DefaultTopBar(
title: String, title: String,
onNavigateBack: (() -> Unit)? = null, onNavigateBack: (() -> Unit)? = null,
onQuit: (() -> Unit)? = null,
onMenuOpen: (() -> Unit)? = null onMenuOpen: (() -> Unit)? = null
) { ) {
TopAppBar( TopAppBar(
title = { Text(title) }, title = { Text(title) },
navigationIcon = { navigationIcon = {
when { when {
onQuit != null -> {
IconButton(onClick = onQuit) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Quit"
)
}
}
onNavigateBack != null -> { onNavigateBack != null -> {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon( 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 package com.example.llama.ui.scaffold.topbar
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Memory 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.llama.monitoring.MemoryMetrics import com.example.llama.monitoring.MemoryMetrics
import com.example.llama.monitoring.TemperatureMetrics import com.example.llama.monitoring.TemperatureMetrics
@ -92,30 +89,32 @@ private fun MemoryIndicator(
val availableGB = String.format(Locale.getDefault(), "%.1f", memoryUsage.availableGB) val availableGB = String.format(Locale.getDefault(), "%.1f", memoryUsage.availableGB)
val totalGB = String.format(Locale.getDefault(), "%.1f", memoryUsage.totalGB) val totalGB = String.format(Locale.getDefault(), "%.1f", memoryUsage.totalGB)
Row( OutlinedButton(
modifier = Modifier.padding(end = 12.dp).clickable(role = Role.Button) { modifier = Modifier.padding(end = 8.dp),
onClick = {
onScaffoldEvent(ScaffoldEvent.ShowSnackbar( onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
message = "Free RAM available: $availableGB GB\nTotal RAM on your device: $totalGB GB", message = "Free RAM available: $availableGB GB\nTotal RAM on your device: $totalGB GB",
withDismissAction = true, withDismissAction = true,
)) ))
}, }
verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Row(verticalAlignment = Alignment.CenterVertically) {
imageVector = Icons.Default.Memory, Icon(
contentDescription = "RAM usage", imageVector = Icons.Default.Memory,
tint = when { contentDescription = "RAM usage",
memoryUsage.availableGB < 1 -> MaterialTheme.colorScheme.error tint = when {
memoryUsage.availableGB < 3 -> MaterialTheme.colorScheme.tertiary memoryUsage.availableGB < 1 -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface memoryUsage.availableGB < 3 -> MaterialTheme.colorScheme.tertiary
} else -> MaterialTheme.colorScheme.onSurface
) }
)
Text( Text(
modifier = Modifier.padding(start = 4.dp), modifier = Modifier.padding(start = 4.dp),
text = "$availableGB / $totalGB GB", text = "$availableGB / $totalGB GB",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
}
} }
} }
@ -134,36 +133,39 @@ private fun TemperatureIndicator(
} }
val warningDismissible = temperatureMetrics.warningLevel == TemperatureWarningLevel.HIGH val warningDismissible = temperatureMetrics.warningLevel == TemperatureWarningLevel.HIGH
Row( OutlinedButton(
modifier = Modifier.padding(end = 12.dp).clickable(role = Role.Button) { modifier = Modifier.padding(end = 8.dp),
onClick = {
onScaffoldEvent(ScaffoldEvent.ShowSnackbar( onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
message = temperatureWarning, message = temperatureWarning,
withDismissAction = warningDismissible, withDismissAction = warningDismissible,
)) ))
}, }
verticalAlignment = Alignment.CenterVertically) { ) {
Icon( Row(verticalAlignment = Alignment.CenterVertically) {
imageVector = when (temperatureMetrics.warningLevel) { Icon(
TemperatureWarningLevel.HIGH -> Icons.Default.WarningAmber imageVector = when (temperatureMetrics.warningLevel) {
else -> Icons.Default.Thermostat TemperatureWarningLevel.HIGH -> Icons.Default.WarningAmber
}, else -> Icons.Default.Thermostat
contentDescription = "Device temperature", },
tint = when (temperatureMetrics.warningLevel) { contentDescription = "Device temperature",
TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error tint = when (temperatureMetrics.warningLevel) {
TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary
} else -> MaterialTheme.colorScheme.onSurface
) }
)
Text( Text(
modifier = Modifier.padding(start = 4.dp), modifier = Modifier.padding(start = 4.dp),
text = temperatureDisplay, text = temperatureDisplay,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = when (temperatureMetrics.warningLevel) { color = when (temperatureMetrics.warningLevel) {
TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error
TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface else -> MaterialTheme.colorScheme.onSurface
} }
) )
}
} }
} }

View File

@ -1,10 +1,7 @@
package com.example.llama.ui.scaffold.topbar package com.example.llama.ui.scaffold.topbar
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.SdStorage 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.llama.monitoring.StorageMetrics import com.example.llama.monitoring.StorageMetrics
import com.example.llama.ui.scaffold.ScaffoldEvent import com.example.llama.ui.scaffold.ScaffoldEvent
@ -65,29 +62,31 @@ private fun StorageIndicator(
val usedGb = String.format(Locale.getDefault(), "%.1f", storageMetrics.usedGB) val usedGb = String.format(Locale.getDefault(), "%.1f", storageMetrics.usedGB)
val availableGb = String.format(Locale.getDefault(), "%.1f", storageMetrics.availableGB) val availableGb = String.format(Locale.getDefault(), "%.1f", storageMetrics.availableGB)
Row( OutlinedButton(
modifier = Modifier.padding(end = 8.dp).clickable(role = Role.Button) { modifier = Modifier.padding(end = 8.dp),
onClick = {
onScaffoldEvent(ScaffoldEvent.ShowSnackbar( onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
message = "Your models occupy $usedGb GB storage\nRemaining free space available: $availableGb GB", message = "Your models occupy $usedGb GB storage\nRemaining free space available: $availableGb GB",
withDismissAction = true, withDismissAction = true,
)) ))
}, }
verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Row(verticalAlignment = Alignment.CenterVertically) {
imageVector = Icons.Default.SdStorage, Icon(
contentDescription = "Storage", imageVector = Icons.Default.SdStorage,
tint = when { contentDescription = "Storage",
storageMetrics.availableGB < 5.0f -> MaterialTheme.colorScheme.error tint = when {
storageMetrics.availableGB < 10.0f -> MaterialTheme.colorScheme.tertiary storageMetrics.availableGB < 5.0f -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface storageMetrics.availableGB < 10.0f -> MaterialTheme.colorScheme.tertiary
} else -> MaterialTheme.colorScheme.onSurface
) }
)
Text( Text(
modifier = Modifier.padding(start = 4.dp), modifier = Modifier.padding(start = 4.dp),
text = "$usedGb / $availableGb GB", text = "$usedGb / $availableGb GB",
style = MaterialTheme.typography.bodySmall 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.MemoryMetrics
import com.example.llama.monitoring.StorageMetrics import com.example.llama.monitoring.StorageMetrics
import com.example.llama.monitoring.TemperatureMetrics import com.example.llama.monitoring.TemperatureMetrics
import com.example.llama.ui.scaffold.ScaffoldEvent
import com.example.llama.viewmodel.ModelScreenUiMode
/** /**
* [TopAppBar] configurations * [TopAppBar] configurations
@ -23,6 +25,19 @@ sealed class TopBarConfig {
override val navigationIcon: NavigationIcon override val navigationIcon: NavigationIcon
) : TopBarConfig() ) : 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 // Performance monitoring top bar with RAM and optional temperature
data class Performance( data class Performance(
override val title: String, override val title: String,
@ -32,7 +47,7 @@ sealed class TopBarConfig {
) : TopBarConfig() ) : TopBarConfig()
// Storage management top bar with used & total storage // Storage management top bar with used & total storage
data class Storage( data class ModelsManagement(
override val title: String, override val title: String,
override val navigationIcon: NavigationIcon, override val navigationIcon: NavigationIcon,
val storageMetrics: StorageMetrics? val storageMetrics: StorageMetrics?
@ -43,7 +58,8 @@ sealed class TopBarConfig {
* Helper class for navigation icon configuration * Helper class for navigation icon configuration
*/ */
sealed class NavigationIcon { sealed class NavigationIcon {
data class Back(val onNavigateBack: () -> Unit) : NavigationIcon()
data class Menu(val onMenuOpen: () -> 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 // UI states
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var isModelCardExpanded by remember { mutableStateOf(false) } var isModelCardExpanded by remember { mutableStateOf(true) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
// Track the actual rendered size of the last message bubble // Track the actual rendered size of the last message bubble
@ -234,7 +234,7 @@ fun ModelCardWithSystemPrompt(
model: ModelInfo, model: ModelInfo,
loadingMetrics: ModelLoadingMetrics, loadingMetrics: ModelLoadingMetrics,
systemPrompt: String?, systemPrompt: String?,
isExpanded: Boolean = false, isExpanded: Boolean = true,
onExpanded: ((Boolean) -> Unit)? = null, onExpanded: ((Boolean) -> Unit)? = null,
) = ModelCardCoreExpandable(model, isExpanded, onExpanded) { ) = ModelCardCoreExpandable(model, isExpanded, onExpanded) {
Spacer(modifier = Modifier.height(8.dp)) 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.Deletion
import com.example.llama.viewmodel.ModelManagementState.Download import com.example.llama.viewmodel.ModelManagementState.Download
import com.example.llama.viewmodel.ModelManagementState.Importation 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.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@ -75,16 +76,16 @@ import java.util.Locale
* Screen for managing LLM models (view, download, delete) * Screen for managing LLM models (view, download, delete)
*/ */
@Composable @Composable
fun ModelsManagementScreen( fun ModelsManagementAndDeletingScreen(
isDeleting: Boolean,
onScaffoldEvent: (ScaffoldEvent) -> Unit, onScaffoldEvent: (ScaffoldEvent) -> Unit,
viewModel: ModelsManagementViewModel, viewModel: ModelsViewModel,
) { ) {
// Data: models // Data: models
val filteredModels by viewModel.filteredModels.collectAsState() val filteredModels by viewModel.filteredModels.collectAsState()
// Selection state // Selection state
val isMultiSelectionMode by viewModel.isMultiSelectionMode.collectAsState() val selectedModels by viewModel.selectedModelsToDelete.collectAsState()
val selectedModels by viewModel.selectedModels.collectAsState()
// Filter state // Filter state
val activeFilters by viewModel.activeFilters.collectAsState() val activeFilters by viewModel.activeFilters.collectAsState()
@ -96,19 +97,13 @@ fun ModelsManagementScreen(
val managementState by viewModel.managementState.collectAsState() val managementState by viewModel.managementState.collectAsState()
// UI states // UI states
var expandedModels = remember { mutableStateMapOf<String, ModelInfo>() } val expandedModels = remember { mutableStateMapOf<String, ModelInfo>() }
BackHandler( BackHandler(
enabled = isMultiSelectionMode enabled = managementState is Importation.Importing
|| managementState is Importation.Importing
|| managementState is Deletion.Deleting || managementState is Deletion.Deleting
) { ) {
if (isMultiSelectionMode) { /* Ignore back press while processing model management requests */
// Exit selection mode if in selection mode
viewModel.toggleSelectionMode(false)
} else {
/* Ignore back press while processing model management requests */
}
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@ -134,13 +129,13 @@ fun ModelsManagementScreen(
) { ) {
items(items = filteredModels, key = { it.id }) { model -> items(items = filteredModels, key = { it.id }) { model ->
val isSelected = val isSelected =
if (isMultiSelectionMode) selectedModels.contains(model.id) else null if (isDeleting) selectedModels.contains(model.id) else null
ModelCardFullExpandable( ModelCardFullExpandable(
model = model, model = model,
isSelected = isSelected, isSelected = isSelected,
onSelected = { onSelected = {
if (isMultiSelectionMode) { if (isDeleting) {
viewModel.toggleModelSelectionById(model.id) viewModel.toggleModelSelectionById(model.id)
} }
}, },
@ -285,7 +280,7 @@ fun ModelsManagementScreen(
is Deletion.Success -> { is Deletion.Success -> {
LaunchedEffect(state) { LaunchedEffect(state) {
viewModel.toggleSelectionMode(false) viewModel.toggleMode(ModelScreenUiMode.MANAGING)
val count = state.models.size val count = state.models.size
onScaffoldEvent( onScaffoldEvent(
@ -641,6 +636,9 @@ private fun BatchDeleteConfirmationDialog(
} }
} }
}, },
containerColor = MaterialTheme.colorScheme.errorContainer,
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
textContentColor = MaterialTheme.colorScheme.onErrorContainer,
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = onConfirm, 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.llama.cpp.gguf.InvalidFileFormatException
import android.net.Uri import android.net.Uri
import android.util.Log 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.llama.data.model.ModelFilter import com.example.llama.data.model.ModelFilter
import com.example.llama.data.model.ModelInfo import com.example.llama.data.model.ModelInfo
import com.example.llama.data.model.ModelSortOrder import com.example.llama.data.model.ModelSortOrder
import com.example.llama.data.model.filterBy 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.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.InsufficientStorageException
import com.example.llama.data.repo.ModelRepository 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.formatFileByteSize
import com.example.llama.util.getFileNameFromUri import com.example.llama.util.getFileNameFromUri
import com.example.llama.util.getFileSizeFromUri import com.example.llama.util.getFileSizeFromUri
import com.example.llama.viewmodel.ModelManagementState.Deletion import com.example.llama.viewmodel.ModelManagementState.Deletion
import com.example.llama.viewmodel.ModelManagementState.Download import com.example.llama.viewmodel.ModelManagementState.Download
import com.example.llama.viewmodel.ModelManagementState.Importation import com.example.llama.viewmodel.ModelManagementState.Importation
import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.FileNotFoundException import java.io.FileNotFoundException
@ -43,70 +53,81 @@ import java.io.IOException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
@OptIn(FlowPreview::class)
@HiltViewModel @HiltViewModel
class ModelsManagementViewModel @Inject constructor( class ModelsViewModel @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val modelRepository: ModelRepository private val modelRepository: ModelRepository,
private val performanceMonitor: PerformanceMonitor,
private val inferenceService: InferenceService,
) : ViewModel() { ) : ViewModel() {
// Data: models // UI state: model management mode
private val _filteredModels = MutableStateFlow<List<ModelInfo>>(emptyList()) private val _modelScreenUiMode = MutableStateFlow(ModelScreenUiMode.BROWSING)
val filteredModels: StateFlow<List<ModelInfo>> = _filteredModels.asStateFlow() val modelScreenUiMode = _modelScreenUiMode.asStateFlow()
// UI state: multi-selection mode fun toggleMode(newMode: ModelScreenUiMode): Boolean {
private val _isMultiSelectionMode = MutableStateFlow(false) val oldMode = _modelScreenUiMode.value
val isMultiSelectionMode: StateFlow<Boolean> = _isMultiSelectionMode.asStateFlow() when (oldMode) {
ModelScreenUiMode.BROWSING -> {
fun toggleSelectionMode(enabled: Boolean) { when (newMode) {
_isMultiSelectionMode.value = enabled ModelScreenUiMode.SEARCHING -> {
if (!enabled) { resetPreselection()
toggleAllSelection(selectAll = false) }
} ModelScreenUiMode.MANAGING -> {
} resetPreselection()
}
// UI state: models selected in multi-selection ModelScreenUiMode.DELETING -> { return false }
private val _selectedModels = MutableStateFlow<Map<String, ModelInfo>>(emptyMap()) else -> { /* No-op */ }
val selectedModels: StateFlow<Map<String, ModelInfo>> = _selectedModels.asStateFlow() }
}
fun toggleModelSelectionById(modelId: String) { ModelScreenUiMode.SEARCHING -> {
val current = _selectedModels.value.toMutableMap() when (newMode) {
val model = _filteredModels.value.find { it.id == modelId } ModelScreenUiMode.BROWSING -> {
searchFieldState.clearText()
if (model != null) { }
if (current.containsKey(modelId)) { else -> { return false }
current.remove(modelId) }
} else { }
current[modelId] = model 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) { // UI state: search mode
if (selectAll) { val searchFieldState = TextFieldState()
_selectedModels.value = _filteredModels.value.associateBy { it.id }
} else {
_selectedModels.value = emptyMap()
}
}
// UI state: sort menu // UI state: sort menu
private val _sortOrder = MutableStateFlow(ModelSortOrder.NAME_ASC) private val _sortOrder = MutableStateFlow(ModelSortOrder.LAST_USED)
val sortOrder: StateFlow<ModelSortOrder> = _sortOrder.asStateFlow() val sortOrder = _sortOrder.asStateFlow()
fun setSortOrder(order: ModelSortOrder) { fun setSortOrder(order: ModelSortOrder) {
_sortOrder.value = order _sortOrder.value = order
} }
private val _showSortMenu = MutableStateFlow(false) private val _showSortMenu = MutableStateFlow(false)
val showSortMenu: StateFlow<Boolean> = _showSortMenu.asStateFlow() val showSortMenu = _showSortMenu.asStateFlow()
fun toggleSortMenu(show: Boolean) { fun toggleSortMenu(visible: Boolean) {
_showSortMenu.value = show _showSortMenu.value = visible
} }
// UI state: filters // UI state: filter menu
private val _activeFilters = MutableStateFlow<Map<ModelFilter, Boolean>>( private val _activeFilters = MutableStateFlow<Map<ModelFilter, Boolean>>(
ModelFilter.ALL_FILTERS.associateWith { false } ModelFilter.ALL_FILTERS.associateWith { false }
) )
@ -127,12 +148,69 @@ class ModelsManagementViewModel @Inject constructor(
} }
private val _showFilterMenu = MutableStateFlow(false) private val _showFilterMenu = MutableStateFlow(false)
val showFilterMenu: StateFlow<Boolean> = _showFilterMenu.asStateFlow() val showFilterMenu = _showFilterMenu.asStateFlow()
fun toggleFilterMenu(visible: Boolean) { fun toggleFilterMenu(visible: Boolean) {
_showFilterMenu.value = visible _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 // UI state: import menu
private val _showImportModelMenu = MutableStateFlow(false) private val _showImportModelMenu = MutableStateFlow(false)
val showImportModelMenu: StateFlow<Boolean> = _showImportModelMenu.asStateFlow() 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 { init {
viewModelScope.launch { viewModelScope.launch {
combine( 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) val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
context.registerReceiver(downloadReceiver, filter, RECEIVER_EXPORTED) context.registerReceiver(downloadReceiver, filter, RECEIVER_EXPORTED)
} }
// Internal state /**
private val _managementState = MutableStateFlow<ModelManagementState>(ModelManagementState.Idle) * Pre-select a model to expand its details and show Run FAB
val managementState: StateFlow<ModelManagementState> = _managementState.asStateFlow() */
fun preselectModel(modelInfo: ModelInfo, preselected: Boolean) {
fun resetManagementState() { _preselectedModelToRun.value = if (preselected) modelInfo else null
huggingFaceQueryJob?.let { _showRamWarning.value = false
if (it.isActive) { it.cancel() }
}
_managementState.value = ModelManagementState.Idle
} }
/**
* 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 * 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 * 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.Deleting(deleted.toFloat() / total, models)
} }
_managementState.value = Deletion.Success(models.values.toList()) _managementState.value = Deletion.Success(models.values.toList())
toggleAllSelectedModelsToDelete(false)
// Reset state after a delay // Reset state after a delay
delay(SUCCESS_RESET_TIMEOUT_MS) delay(DELETE_SUCCESS_RESET_TIMEOUT_MS)
_managementState.value = ModelManagementState.Idle _managementState.value = ModelManagementState.Idle
} catch (e: Exception) { } catch (e: Exception) {
_managementState.value = Deletion.Error( _managementState.value = Deletion.Error(
@ -371,12 +524,35 @@ class ModelsManagementViewModel @Inject constructor(
} }
companion object { 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 { sealed class ModelManagementState {
object Idle : ModelManagementState() object Idle : ModelManagementState()