UI: merge the Model Selection and Model Management into a unified Models screen
This commit is contained in:
parent
29f263440f
commit
df16abe75e
|
|
@ -46,17 +46,16 @@ import com.example.llama.ui.scaffold.topbar.TopBarConfig
|
|||
import com.example.llama.ui.screens.BenchmarkScreen
|
||||
import com.example.llama.ui.screens.ConversationScreen
|
||||
import com.example.llama.ui.screens.ModelLoadingScreen
|
||||
import com.example.llama.ui.screens.ModelSelectionScreen
|
||||
import com.example.llama.ui.screens.ModelsManagementScreen
|
||||
import com.example.llama.ui.screens.ModelsScreen
|
||||
import com.example.llama.ui.screens.SettingsGeneralScreen
|
||||
import com.example.llama.ui.theme.LlamaTheme
|
||||
import com.example.llama.viewmodel.BenchmarkViewModel
|
||||
import com.example.llama.viewmodel.ConversationViewModel
|
||||
import com.example.llama.viewmodel.MainViewModel
|
||||
import com.example.llama.viewmodel.ModelLoadingViewModel
|
||||
import com.example.llama.viewmodel.ModelSelectionViewModel
|
||||
import com.example.llama.viewmodel.ModelsManagementViewModel
|
||||
import com.example.llama.viewmodel.ModelsViewModel
|
||||
import com.example.llama.viewmodel.SettingsViewModel
|
||||
import com.example.llama.viewmodel.ModelScreenUiMode
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -85,11 +84,11 @@ class MainActivity : ComponentActivity() {
|
|||
fun AppContent(
|
||||
settingsViewModel: SettingsViewModel,
|
||||
mainViewModel: MainViewModel = hiltViewModel(),
|
||||
modelSelectionViewModel: ModelSelectionViewModel = hiltViewModel(),
|
||||
modelsViewModel: ModelsViewModel = hiltViewModel(),
|
||||
modelLoadingViewModel: ModelLoadingViewModel = hiltViewModel(),
|
||||
benchmarkViewModel: BenchmarkViewModel = hiltViewModel(),
|
||||
conversationViewModel: ConversationViewModel = hiltViewModel(),
|
||||
modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
|
||||
// modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
|
@ -97,6 +96,9 @@ fun AppContent(
|
|||
// Inference engine state
|
||||
val engineState by mainViewModel.engineState.collectAsState()
|
||||
|
||||
// Model state
|
||||
val modelScreenUiMode by modelsViewModel.modelScreenUiMode.collectAsState()
|
||||
|
||||
// Metric states for scaffolds
|
||||
val isMonitoringEnabled by settingsViewModel.isMonitoringEnabled.collectAsState()
|
||||
val memoryUsage by settingsViewModel.memoryUsage.collectAsState()
|
||||
|
|
@ -187,60 +189,157 @@ fun AppContent(
|
|||
// Create scaffold's top & bottom bar configs based on current route
|
||||
val scaffoldConfig = when {
|
||||
// Model selection screen
|
||||
currentRoute == AppDestinations.MODEL_SELECTION_ROUTE -> {
|
||||
currentRoute == AppDestinations.MODELS_ROUTE -> {
|
||||
// Collect states for bottom bar
|
||||
val isSearchActive by modelSelectionViewModel.isSearchActive.collectAsState()
|
||||
val sortOrder by modelSelectionViewModel.sortOrder.collectAsState()
|
||||
val showSortMenu by modelSelectionViewModel.showSortMenu.collectAsState()
|
||||
val activeFilters by modelSelectionViewModel.activeFilters.collectAsState()
|
||||
val showFilterMenu by modelSelectionViewModel.showFilterMenu.collectAsState()
|
||||
val preselection by modelSelectionViewModel.preselection.collectAsState()
|
||||
val sortOrder by modelsViewModel.sortOrder.collectAsState()
|
||||
val showSortMenu by modelsViewModel.showSortMenu.collectAsState()
|
||||
val activeFilters by modelsViewModel.activeFilters.collectAsState()
|
||||
val showFilterMenu by modelsViewModel.showFilterMenu.collectAsState()
|
||||
val preselection by modelsViewModel.preselectedModelToRun.collectAsState()
|
||||
|
||||
val selectedModelsToDelete by modelsViewModel.selectedModelsToDelete.collectAsState()
|
||||
val showImportModelMenu by modelsViewModel.showImportModelMenu.collectAsState()
|
||||
|
||||
// Create file launcher for importing local models
|
||||
val fileLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri -> uri?.let { modelsViewModel.importLocalModelFileSelected(it) } }
|
||||
|
||||
ScaffoldConfig(
|
||||
topBarConfig =
|
||||
if (isSearchActive) TopBarConfig.None()
|
||||
else TopBarConfig.Default(
|
||||
title = "Pick your model",
|
||||
navigationIcon = NavigationIcon.Menu {
|
||||
modelSelectionViewModel.resetPreselection()
|
||||
openDrawer()
|
||||
}
|
||||
),
|
||||
bottomBarConfig = BottomBarConfig.ModelSelection(
|
||||
search = BottomBarConfig.ModelSelection.SearchConfig(
|
||||
isActive = isSearchActive,
|
||||
onToggleSearch = modelSelectionViewModel::toggleSearchState,
|
||||
textFieldState = modelSelectionViewModel.searchFieldState,
|
||||
onSearch = { /* No-op for now */ }
|
||||
),
|
||||
sorting = BottomBarConfig.ModelSelection.SortingConfig(
|
||||
currentOrder = sortOrder,
|
||||
isMenuVisible = showSortMenu,
|
||||
toggleMenu = modelSelectionViewModel::toggleSortMenu,
|
||||
selectOrder = {
|
||||
modelSelectionViewModel.setSortOrder(it)
|
||||
modelSelectionViewModel.toggleSortMenu(false)
|
||||
}
|
||||
),
|
||||
filtering = BottomBarConfig.ModelSelection.FilteringConfig(
|
||||
isActive = activeFilters.any { it.value },
|
||||
filters = activeFilters,
|
||||
onToggleFilter = modelSelectionViewModel::toggleFilter,
|
||||
onClearFilters = modelSelectionViewModel::clearFilters,
|
||||
isMenuVisible = showFilterMenu,
|
||||
toggleMenu = modelSelectionViewModel::toggleFilterMenu
|
||||
),
|
||||
runAction = BottomBarConfig.ModelSelection.RunActionConfig(
|
||||
preselection = preselection,
|
||||
onClickRun = {
|
||||
if (modelSelectionViewModel.selectModel(it)) {
|
||||
modelSelectionViewModel.toggleSearchState(false)
|
||||
modelSelectionViewModel.resetPreselection()
|
||||
navigationActions.navigateToModelLoading()
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
when (modelScreenUiMode) {
|
||||
ModelScreenUiMode.BROWSING ->
|
||||
TopBarConfig.ModelsBrowsing(
|
||||
title = "Installed models",
|
||||
navigationIcon = NavigationIcon.Menu {
|
||||
modelsViewModel.resetPreselection()
|
||||
openDrawer()
|
||||
},
|
||||
onToggleMode = modelsViewModel::toggleMode,
|
||||
)
|
||||
ModelScreenUiMode.SEARCHING ->
|
||||
TopBarConfig.None()
|
||||
ModelScreenUiMode.MANAGING ->
|
||||
TopBarConfig.ModelsManagement(
|
||||
title = "Managing models",
|
||||
navigationIcon = NavigationIcon.Back {
|
||||
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
|
||||
},
|
||||
storageMetrics = if (isMonitoringEnabled) storageMetrics else null,
|
||||
)
|
||||
ModelScreenUiMode.DELETING ->
|
||||
TopBarConfig.ModelsDeleting(
|
||||
title = "Deleting models",
|
||||
navigationIcon = NavigationIcon.Quit {
|
||||
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBarConfig =
|
||||
when (modelScreenUiMode) {
|
||||
ModelScreenUiMode.BROWSING ->
|
||||
BottomBarConfig.Models.Browsing(
|
||||
onToggleSearching = {
|
||||
modelsViewModel.toggleMode(ModelScreenUiMode.SEARCHING)
|
||||
},
|
||||
sorting = BottomBarConfig.Models.Browsing.SortingConfig(
|
||||
currentOrder = sortOrder,
|
||||
isMenuVisible = showSortMenu,
|
||||
toggleMenu = modelsViewModel::toggleSortMenu,
|
||||
selectOrder = {
|
||||
modelsViewModel.setSortOrder(it)
|
||||
modelsViewModel.toggleSortMenu(false)
|
||||
}
|
||||
),
|
||||
filtering = BottomBarConfig.Models.Browsing.FilteringConfig(
|
||||
isActive = activeFilters.any { it.value },
|
||||
filters = activeFilters,
|
||||
onToggleFilter = modelsViewModel::toggleFilter,
|
||||
onClearFilters = modelsViewModel::clearFilters,
|
||||
isMenuVisible = showFilterMenu,
|
||||
toggleMenu = modelsViewModel::toggleFilterMenu
|
||||
),
|
||||
runAction = BottomBarConfig.Models.RunActionConfig(
|
||||
preselectedModelToRun = preselection,
|
||||
onClickRun = {
|
||||
if (modelsViewModel.selectModel(it)) {
|
||||
modelsViewModel.resetPreselection()
|
||||
navigationActions.navigateToModelLoading()
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
ModelScreenUiMode.SEARCHING ->
|
||||
BottomBarConfig.Models.Searching(
|
||||
textFieldState = modelsViewModel.searchFieldState,
|
||||
onQuitSearching = {
|
||||
modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
|
||||
},
|
||||
onSearch = { /* No-op for now */ },
|
||||
runAction = BottomBarConfig.Models.RunActionConfig(
|
||||
preselectedModelToRun = preselection,
|
||||
onClickRun = {
|
||||
if (modelsViewModel.selectModel(it)) {
|
||||
modelsViewModel.resetPreselection()
|
||||
navigationActions.navigateToModelLoading()
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
ModelScreenUiMode.MANAGING ->
|
||||
BottomBarConfig.Models.Management(
|
||||
sorting = BottomBarConfig.Models.Management.SortingConfig(
|
||||
currentOrder = sortOrder,
|
||||
isMenuVisible = showSortMenu,
|
||||
toggleMenu = { modelsViewModel.toggleSortMenu(it) },
|
||||
selectOrder = {
|
||||
modelsViewModel.setSortOrder(it)
|
||||
modelsViewModel.toggleSortMenu(false)
|
||||
}
|
||||
),
|
||||
filtering = BottomBarConfig.Models.Management.FilteringConfig(
|
||||
isActive = activeFilters.any { it.value },
|
||||
filters = activeFilters,
|
||||
onToggleFilter = modelsViewModel::toggleFilter,
|
||||
onClearFilters = modelsViewModel::clearFilters,
|
||||
isMenuVisible = showFilterMenu,
|
||||
toggleMenu = modelsViewModel::toggleFilterMenu
|
||||
),
|
||||
importing = BottomBarConfig.Models.Management.ImportConfig(
|
||||
isMenuVisible = showImportModelMenu,
|
||||
toggleMenu = { show -> modelsViewModel.toggleImportMenu(show) },
|
||||
importFromLocal = {
|
||||
fileLauncher.launch(arrayOf("application/octet-stream", "*/*"))
|
||||
modelsViewModel.toggleImportMenu(false)
|
||||
},
|
||||
importFromHuggingFace = {
|
||||
modelsViewModel.queryModelsFromHuggingFace()
|
||||
modelsViewModel.toggleImportMenu(false)
|
||||
}
|
||||
),
|
||||
onToggleDeleting = {
|
||||
modelsViewModel.toggleMode(ModelScreenUiMode.DELETING)
|
||||
}
|
||||
)
|
||||
|
||||
ModelScreenUiMode.DELETING ->
|
||||
BottomBarConfig.Models.Deleting(
|
||||
onQuitDeleting = {
|
||||
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
||||
},
|
||||
selectedModels = selectedModelsToDelete,
|
||||
toggleAllSelection = { modelsViewModel.toggleAllSelectedModelsToDelete(it) },
|
||||
deleteSelected = {
|
||||
selectedModelsToDelete.let {
|
||||
if (it.isNotEmpty()) {
|
||||
modelsViewModel.batchDeletionClicked(it)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -327,75 +426,6 @@ fun AppContent(
|
|||
)
|
||||
)
|
||||
|
||||
// Storage management screen
|
||||
currentRoute == AppDestinations.MODELS_MANAGEMENT_ROUTE -> {
|
||||
// Collect the needed states
|
||||
val sortOrder by modelsManagementViewModel.sortOrder.collectAsState()
|
||||
val isMultiSelectionMode by modelsManagementViewModel.isMultiSelectionMode.collectAsState()
|
||||
val selectedModels by modelsManagementViewModel.selectedModels.collectAsState()
|
||||
val showSortMenu by modelsManagementViewModel.showSortMenu.collectAsState()
|
||||
val activeFilters by modelsManagementViewModel.activeFilters.collectAsState()
|
||||
val showFilterMenu by modelsManagementViewModel.showFilterMenu.collectAsState()
|
||||
val showImportModelMenu by modelsManagementViewModel.showImportModelMenu.collectAsState()
|
||||
|
||||
// Create file launcher for importing local models
|
||||
val fileLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri -> uri?.let { modelsManagementViewModel.importLocalModelFileSelected(it) } }
|
||||
|
||||
val bottomBarConfig = BottomBarConfig.ModelsManagement(
|
||||
sorting = BottomBarConfig.ModelsManagement.SortingConfig(
|
||||
currentOrder = sortOrder,
|
||||
isMenuVisible = showSortMenu,
|
||||
toggleMenu = { modelsManagementViewModel.toggleSortMenu(it) },
|
||||
selectOrder = {
|
||||
modelsManagementViewModel.setSortOrder(it)
|
||||
modelsManagementViewModel.toggleSortMenu(false)
|
||||
}
|
||||
),
|
||||
filtering = BottomBarConfig.ModelsManagement.FilteringConfig(
|
||||
isActive = activeFilters.any { it.value },
|
||||
filters = activeFilters,
|
||||
onToggleFilter = modelsManagementViewModel::toggleFilter,
|
||||
onClearFilters = modelsManagementViewModel::clearFilters,
|
||||
isMenuVisible = showFilterMenu,
|
||||
toggleMenu = modelsManagementViewModel::toggleFilterMenu
|
||||
),
|
||||
selection = BottomBarConfig.ModelsManagement.SelectionConfig(
|
||||
isActive = isMultiSelectionMode,
|
||||
toggleMode = { modelsManagementViewModel.toggleSelectionMode(it) },
|
||||
selectedModels = selectedModels,
|
||||
toggleAllSelection = { modelsManagementViewModel.toggleAllSelection(it) },
|
||||
deleteSelected = {
|
||||
if (selectedModels.isNotEmpty()) {
|
||||
modelsManagementViewModel.batchDeletionClicked(selectedModels)
|
||||
}
|
||||
},
|
||||
),
|
||||
importing = BottomBarConfig.ModelsManagement.ImportConfig(
|
||||
isMenuVisible = showImportModelMenu,
|
||||
toggleMenu = { show -> modelsManagementViewModel.toggleImportMenu(show) },
|
||||
importFromLocal = {
|
||||
fileLauncher.launch(arrayOf("application/octet-stream", "*/*"))
|
||||
modelsManagementViewModel.toggleImportMenu(false)
|
||||
},
|
||||
importFromHuggingFace = {
|
||||
modelsManagementViewModel.queryModelsFromHuggingFace()
|
||||
modelsManagementViewModel.toggleImportMenu(false)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
ScaffoldConfig(
|
||||
topBarConfig = TopBarConfig.Storage(
|
||||
title = "Models Management",
|
||||
navigationIcon = NavigationIcon.Back { navigationActions.navigateUp() },
|
||||
storageMetrics = if (isMonitoringEnabled) storageMetrics else null,
|
||||
),
|
||||
bottomBarConfig = bottomBarConfig
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback for empty screen or unknown routes
|
||||
else -> ScaffoldConfig(
|
||||
topBarConfig = TopBarConfig.Default(title = "", navigationIcon = NavigationIcon.None)
|
||||
|
|
@ -419,22 +449,22 @@ fun AppContent(
|
|||
// AnimatedNavHost inside the scaffold content
|
||||
AnimatedNavHost(
|
||||
navController = navController,
|
||||
startDestination = AppDestinations.MODEL_SELECTION_ROUTE,
|
||||
startDestination = AppDestinations.MODELS_ROUTE,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
// Model Selection Screen
|
||||
composable(AppDestinations.MODEL_SELECTION_ROUTE) {
|
||||
ModelSelectionScreen(
|
||||
composable(AppDestinations.MODELS_ROUTE) {
|
||||
ModelsScreen(
|
||||
onManageModelsClicked = {
|
||||
navigationActions.navigateToModelsManagement()
|
||||
// TODO-han.yin: remove this after implementing onboarding flow
|
||||
},
|
||||
onConfirmSelection = { modelInfo, ramWarning ->
|
||||
if (modelSelectionViewModel.confirmSelectedModel(modelInfo, ramWarning)) {
|
||||
if (modelsViewModel.confirmSelectedModel(modelInfo, ramWarning)) {
|
||||
navigationActions.navigateToModelLoading()
|
||||
modelSelectionViewModel.toggleSearchState(false)
|
||||
}
|
||||
},
|
||||
viewModel = modelSelectionViewModel
|
||||
onScaffoldEvent = handleScaffoldEvent,
|
||||
viewModel = modelsViewModel
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -502,14 +532,6 @@ fun AppContent(
|
|||
)
|
||||
}
|
||||
|
||||
// Models Management Screen
|
||||
composable(AppDestinations.MODELS_MANAGEMENT_ROUTE) {
|
||||
ModelsManagementScreen(
|
||||
onScaffoldEvent = handleScaffoldEvent,
|
||||
viewModel = modelsManagementViewModel
|
||||
)
|
||||
}
|
||||
|
||||
// General Settings Screen
|
||||
composable(AppDestinations.SETTINGS_GENERAL_ROUTE) {
|
||||
SettingsGeneralScreen(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import com.example.llama.engine.ModelLoadingMetrics
|
|||
*/
|
||||
object AppDestinations {
|
||||
// Primary navigation destinations
|
||||
const val MODEL_SELECTION_ROUTE = "model_selection"
|
||||
const val MODELS_ROUTE = "models"
|
||||
const val MODEL_LOADING_ROUTE = "model_loading"
|
||||
|
||||
const val CONVERSATION_ROUTE = "conversation"
|
||||
|
|
@ -19,7 +19,6 @@ object AppDestinations {
|
|||
|
||||
// Settings destinations
|
||||
const val SETTINGS_GENERAL_ROUTE = "settings_general"
|
||||
const val MODELS_MANAGEMENT_ROUTE = "models_management"
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -28,9 +27,9 @@ object AppDestinations {
|
|||
class NavigationActions(private val navController: NavController) {
|
||||
|
||||
fun navigateToModelSelection() {
|
||||
navController.navigate(AppDestinations.MODEL_SELECTION_ROUTE) {
|
||||
navController.navigate(AppDestinations.MODELS_ROUTE) {
|
||||
// Clear back stack to start fresh
|
||||
popUpTo(AppDestinations.MODEL_SELECTION_ROUTE) { inclusive = true }
|
||||
popUpTo(AppDestinations.MODELS_ROUTE) { inclusive = true }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,10 +54,6 @@ class NavigationActions(private val navController: NavController) {
|
|||
navController.navigate(AppDestinations.SETTINGS_GENERAL_ROUTE)
|
||||
}
|
||||
|
||||
fun navigateToModelsManagement() {
|
||||
navController.navigate(AppDestinations.MODELS_MANAGEMENT_ROUTE)
|
||||
}
|
||||
|
||||
fun navigateUp() {
|
||||
navController.navigateUp()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -149,7 +148,6 @@ fun ModelCardCoreExpandable(
|
|||
* @param isExpanded Whether additional details is expanded or shrunk
|
||||
* @param onExpanded Action to perform when the card is expanded or shrunk
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ModelCardFullExpandable(
|
||||
model: ModelInfo,
|
||||
|
|
@ -158,10 +156,6 @@ fun ModelCardFullExpandable(
|
|||
isExpanded: Boolean = false,
|
||||
onExpanded: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
LaunchedEffect(model) {
|
||||
android.util.Log.w("JOJO", model.languages.toString())
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@ import androidx.compose.runtime.remember
|
|||
import com.example.llama.ui.scaffold.bottombar.BenchmarkBottomBar
|
||||
import com.example.llama.ui.scaffold.bottombar.BottomBarConfig
|
||||
import com.example.llama.ui.scaffold.bottombar.ConversationBottomBar
|
||||
import com.example.llama.ui.scaffold.bottombar.ModelSelectionBottomBar
|
||||
import com.example.llama.ui.scaffold.bottombar.ModelsBrowsingBottomBar
|
||||
import com.example.llama.ui.scaffold.bottombar.ModelsDeletingBottomBar
|
||||
import com.example.llama.ui.scaffold.bottombar.ModelsManagementBottomBar
|
||||
import com.example.llama.ui.scaffold.bottombar.ModelsSearchingBottomBar
|
||||
import com.example.llama.ui.scaffold.topbar.DefaultTopBar
|
||||
import com.example.llama.ui.scaffold.topbar.ModelsBrowsingTopBar
|
||||
import com.example.llama.ui.scaffold.topbar.NavigationIcon
|
||||
import com.example.llama.ui.scaffold.topbar.PerformanceTopBar
|
||||
import com.example.llama.ui.scaffold.topbar.StorageTopBar
|
||||
|
|
@ -65,6 +68,25 @@ fun AppScaffold(
|
|||
onMenuOpen = topBarconfig.navigationIcon.menuAction,
|
||||
)
|
||||
|
||||
is TopBarConfig.ModelsBrowsing -> ModelsBrowsingTopBar(
|
||||
title = topBarconfig.title,
|
||||
onToggleMode = topBarconfig.onToggleMode,
|
||||
onNavigateBack = topBarconfig.navigationIcon.backAction,
|
||||
onMenuOpen = topBarconfig.navigationIcon.menuAction
|
||||
)
|
||||
|
||||
is TopBarConfig.ModelsDeleting -> DefaultTopBar(
|
||||
title = topBarconfig.title,
|
||||
onQuit = topBarconfig.navigationIcon.quitAction
|
||||
)
|
||||
|
||||
is TopBarConfig.ModelsManagement -> StorageTopBar(
|
||||
title = topBarconfig.title,
|
||||
storageMetrics = topBarconfig.storageMetrics,
|
||||
onScaffoldEvent = onScaffoldEvent,
|
||||
onNavigateBack = topBarconfig.navigationIcon.backAction,
|
||||
)
|
||||
|
||||
is TopBarConfig.Performance -> PerformanceTopBar(
|
||||
title = topBarconfig.title,
|
||||
memoryMetrics = topBarconfig.memoryMetrics,
|
||||
|
|
@ -73,13 +95,6 @@ fun AppScaffold(
|
|||
onNavigateBack = topBarconfig.navigationIcon.backAction,
|
||||
onMenuOpen = topBarconfig.navigationIcon.menuAction,
|
||||
)
|
||||
|
||||
is TopBarConfig.Storage -> StorageTopBar(
|
||||
title = topBarconfig.title,
|
||||
storageMetrics = topBarconfig.storageMetrics,
|
||||
onScaffoldEvent = onScaffoldEvent,
|
||||
onNavigateBack = topBarconfig.navigationIcon.backAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,24 +102,37 @@ fun AppScaffold(
|
|||
when (val config = bottomBarConfig) {
|
||||
is BottomBarConfig.None -> { /* No bottom bar */ }
|
||||
|
||||
is BottomBarConfig.ModelSelection -> {
|
||||
ModelSelectionBottomBar(
|
||||
search = config.search,
|
||||
sorting = config.sorting,
|
||||
filtering = config.filtering,
|
||||
runAction = config.runAction
|
||||
is BottomBarConfig.Models.Browsing -> {
|
||||
ModelsBrowsingBottomBar(
|
||||
onToggleSearching = config.onToggleSearching,
|
||||
sortingConfig = config.sorting,
|
||||
filteringConfig = config.filtering,
|
||||
runActionConfig = config.runAction
|
||||
)
|
||||
}
|
||||
|
||||
is BottomBarConfig.ModelsManagement -> {
|
||||
ModelsManagementBottomBar(
|
||||
sorting = config.sorting,
|
||||
filtering = config.filtering,
|
||||
selection = config.selection,
|
||||
importing = config.importing,
|
||||
is BottomBarConfig.Models.Searching -> {
|
||||
ModelsSearchingBottomBar(
|
||||
textFieldState = config.textFieldState,
|
||||
onQuitSearching = config.onQuitSearching,
|
||||
onSearch = config.onSearch,
|
||||
runActionConfig = config.runAction,
|
||||
)
|
||||
}
|
||||
|
||||
is BottomBarConfig.Models.Management -> {
|
||||
ModelsManagementBottomBar(
|
||||
sortingConfig = config.sorting,
|
||||
filteringConfig = config.filtering,
|
||||
importingConfig = config.importing,
|
||||
onToggleDeleting = config.onToggleDeleting
|
||||
)
|
||||
}
|
||||
|
||||
is BottomBarConfig.Models.Deleting -> {
|
||||
ModelsDeletingBottomBar(config)
|
||||
}
|
||||
|
||||
is BottomBarConfig.Benchmark -> {
|
||||
BenchmarkBottomBar(
|
||||
showShareFab = config.showShareFab,
|
||||
|
|
@ -141,8 +169,11 @@ fun AppScaffold(
|
|||
}
|
||||
|
||||
// Helper functions to obtain navigation actions if exist
|
||||
private val NavigationIcon.menuAction: (() -> Unit)?
|
||||
get() = (this as? NavigationIcon.Menu)?.onMenuOpen
|
||||
|
||||
private val NavigationIcon.backAction: (() -> Unit)?
|
||||
get() = (this as? NavigationIcon.Back)?.onNavigateBack
|
||||
|
||||
private val NavigationIcon.menuAction: (() -> Unit)?
|
||||
get() = (this as? NavigationIcon.Menu)?.onMenuOpen
|
||||
private val NavigationIcon.quitAction: (() -> Unit)?
|
||||
get() = (this as? NavigationIcon.Quit)?.onQuit
|
||||
|
|
|
|||
|
|
@ -122,20 +122,21 @@ private fun DrawerContent(
|
|||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Main Navigation Items
|
||||
Text(
|
||||
text = "Navigation",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
|
||||
)
|
||||
// // Main Navigation Items
|
||||
// TODO-han.yin: add back once we add more features
|
||||
// Text(
|
||||
// text = "Features",
|
||||
// style = MaterialTheme.typography.labelMedium,
|
||||
// color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
|
||||
// )
|
||||
|
||||
DrawerNavigationItem(
|
||||
icon = Icons.Default.Home,
|
||||
label = "Home",
|
||||
isSelected = currentRoute == AppDestinations.MODEL_SELECTION_ROUTE,
|
||||
label = "Models",
|
||||
isSelected = currentRoute == AppDestinations.MODELS_ROUTE,
|
||||
onClick = {
|
||||
if (currentRoute != AppDestinations.MODEL_SELECTION_ROUTE) {
|
||||
if (currentRoute != AppDestinations.MODELS_ROUTE) {
|
||||
onNavigate { navigationActions.navigateToModelSelection() }
|
||||
} else {
|
||||
onNavigate { /* No-op: simply close drawer */ }
|
||||
|
|
@ -143,22 +144,15 @@ private fun DrawerContent(
|
|||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Settings Group
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
|
||||
)
|
||||
|
||||
DrawerNavigationItem(
|
||||
icon = Icons.Default.Folder,
|
||||
label = "Models",
|
||||
isSelected = currentRoute == AppDestinations.MODELS_MANAGEMENT_ROUTE,
|
||||
onClick = { onNavigate { navigationActions.navigateToModelsManagement() } }
|
||||
)
|
||||
// Spacer(modifier = Modifier.height(24.dp))
|
||||
// TODO-han.yin: add back once we add more features
|
||||
// // Settings Group
|
||||
// Text(
|
||||
// text = "Settings",
|
||||
// style = MaterialTheme.typography.labelMedium,
|
||||
// color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
|
||||
// )
|
||||
|
||||
DrawerNavigationItem(
|
||||
icon = Icons.Default.Settings,
|
||||
|
|
|
|||
|
|
@ -64,10 +64,7 @@ fun BenchmarkBottomBar(
|
|||
enter = scaleIn() + fadeIn(),
|
||||
exit = scaleOut() + fadeOut()
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = onShare,
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
FloatingActionButton(onClick = onShare) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "Share the benchmark results"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import androidx.compose.foundation.text.input.TextFieldState
|
|||
import com.example.llama.data.model.ModelFilter
|
||||
import com.example.llama.data.model.ModelInfo
|
||||
import com.example.llama.data.model.ModelSortOrder
|
||||
import com.example.llama.viewmodel.Preselection
|
||||
import com.example.llama.viewmodel.PreselectedModelToRun
|
||||
|
||||
/**
|
||||
* [BottomAppBar] configurations
|
||||
|
|
@ -13,76 +13,78 @@ sealed class BottomBarConfig {
|
|||
|
||||
object None : BottomBarConfig()
|
||||
|
||||
data class ModelSelection(
|
||||
val search: SearchConfig,
|
||||
val sorting: SortingConfig,
|
||||
val filtering: FilteringConfig,
|
||||
val runAction: RunActionConfig
|
||||
) : BottomBarConfig() {
|
||||
data class SearchConfig(
|
||||
val isActive: Boolean,
|
||||
val onToggleSearch: (Boolean) -> Unit,
|
||||
sealed class Models : BottomBarConfig() {
|
||||
|
||||
data class Browsing(
|
||||
val onToggleSearching: () -> Unit,
|
||||
val sorting: SortingConfig,
|
||||
val filtering: FilteringConfig,
|
||||
val runAction: RunActionConfig,
|
||||
) : BottomBarConfig() {
|
||||
data class SortingConfig(
|
||||
val currentOrder: ModelSortOrder,
|
||||
val isMenuVisible: Boolean,
|
||||
val toggleMenu: (Boolean) -> Unit,
|
||||
val selectOrder: (ModelSortOrder) -> Unit
|
||||
)
|
||||
|
||||
data class FilteringConfig(
|
||||
val isActive: Boolean,
|
||||
val filters: Map<ModelFilter, Boolean>,
|
||||
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
|
||||
val onClearFilters: () -> Unit,
|
||||
val isMenuVisible: Boolean,
|
||||
val toggleMenu: (Boolean) -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
data class Searching(
|
||||
val textFieldState: TextFieldState,
|
||||
val onQuitSearching: () -> Unit,
|
||||
val onSearch: (String) -> Unit,
|
||||
)
|
||||
val runAction: RunActionConfig,
|
||||
) : BottomBarConfig()
|
||||
|
||||
data class SortingConfig(
|
||||
val currentOrder: ModelSortOrder,
|
||||
val isMenuVisible: Boolean,
|
||||
val toggleMenu: (Boolean) -> Unit,
|
||||
val selectOrder: (ModelSortOrder) -> Unit
|
||||
)
|
||||
data class Management(
|
||||
val sorting: SortingConfig,
|
||||
val filtering: FilteringConfig,
|
||||
val importing: ImportConfig,
|
||||
val onToggleDeleting: () -> Unit,
|
||||
) : BottomBarConfig() {
|
||||
data class SortingConfig(
|
||||
val currentOrder: ModelSortOrder,
|
||||
val isMenuVisible: Boolean,
|
||||
val toggleMenu: (Boolean) -> Unit,
|
||||
val selectOrder: (ModelSortOrder) -> Unit
|
||||
)
|
||||
|
||||
data class FilteringConfig(
|
||||
val isActive: Boolean,
|
||||
val filters: Map<ModelFilter, Boolean>,
|
||||
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
|
||||
val onClearFilters: () -> Unit,
|
||||
val isMenuVisible: Boolean,
|
||||
val toggleMenu: (Boolean) -> Unit
|
||||
)
|
||||
data class FilteringConfig(
|
||||
val isActive: Boolean,
|
||||
val filters: Map<ModelFilter, Boolean>,
|
||||
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
|
||||
val onClearFilters: () -> Unit,
|
||||
val isMenuVisible: Boolean,
|
||||
val toggleMenu: (Boolean) -> Unit
|
||||
)
|
||||
|
||||
data class RunActionConfig(
|
||||
val preselection: Preselection?,
|
||||
val onClickRun: (Preselection) -> Unit,
|
||||
)
|
||||
}
|
||||
data class ImportConfig(
|
||||
val isMenuVisible: Boolean,
|
||||
val toggleMenu: (Boolean) -> Unit,
|
||||
val importFromLocal: () -> Unit,
|
||||
val importFromHuggingFace: () -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
data class ModelsManagement(
|
||||
val sorting: SortingConfig,
|
||||
val filtering: FilteringConfig,
|
||||
val selection: SelectionConfig,
|
||||
val importing: ImportConfig
|
||||
) : BottomBarConfig() {
|
||||
data class SortingConfig(
|
||||
val currentOrder: ModelSortOrder,
|
||||
val isMenuVisible: Boolean,
|
||||
val toggleMenu: (Boolean) -> Unit,
|
||||
val selectOrder: (ModelSortOrder) -> Unit
|
||||
)
|
||||
|
||||
data class FilteringConfig(
|
||||
val isActive: Boolean,
|
||||
val filters: Map<ModelFilter, Boolean>,
|
||||
val onToggleFilter: (ModelFilter, Boolean) -> Unit,
|
||||
val onClearFilters: () -> Unit,
|
||||
val isMenuVisible: Boolean,
|
||||
val toggleMenu: (Boolean) -> Unit
|
||||
)
|
||||
|
||||
data class SelectionConfig(
|
||||
val isActive: Boolean,
|
||||
val toggleMode: (Boolean) -> Unit,
|
||||
data class Deleting(
|
||||
val onQuitDeleting: () -> Unit,
|
||||
val selectedModels: Map<String, ModelInfo>,
|
||||
val toggleAllSelection: (Boolean) -> Unit,
|
||||
val deleteSelected: () -> Unit
|
||||
)
|
||||
) : BottomBarConfig()
|
||||
|
||||
data class ImportConfig(
|
||||
val isMenuVisible: Boolean,
|
||||
val toggleMenu: (Boolean) -> Unit,
|
||||
val importFromLocal: () -> Unit,
|
||||
val importFromHuggingFace: () -> Unit
|
||||
data class RunActionConfig(
|
||||
val preselectedModelToRun: PreselectedModelToRun?,
|
||||
val onClickRun: (PreselectedModelToRun) -> Unit,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -6,12 +6,8 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.automirrored.filled.Sort
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ClearAll
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FilterAlt
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material.icons.outlined.DeleteSweep
|
||||
import androidx.compose.material.icons.outlined.FilterAlt
|
||||
import androidx.compose.material.icons.outlined.FilterAltOff
|
||||
|
|
@ -35,176 +31,138 @@ import com.example.llama.data.model.ModelSortOrder
|
|||
|
||||
@Composable
|
||||
fun ModelsManagementBottomBar(
|
||||
sorting: BottomBarConfig.ModelsManagement.SortingConfig,
|
||||
filtering: BottomBarConfig.ModelsManagement.FilteringConfig,
|
||||
selection: BottomBarConfig.ModelsManagement.SelectionConfig,
|
||||
importing: BottomBarConfig.ModelsManagement.ImportConfig
|
||||
sortingConfig: BottomBarConfig.Models.Management.SortingConfig,
|
||||
filteringConfig: BottomBarConfig.Models.Management.FilteringConfig,
|
||||
importingConfig: BottomBarConfig.Models.Management.ImportConfig,
|
||||
onToggleDeleting: () -> Unit,
|
||||
) {
|
||||
BottomAppBar(
|
||||
actions = {
|
||||
if (selection.isActive) {
|
||||
/* Multi-selection mode actions */
|
||||
IconButton(
|
||||
onClick = selection.deleteSelected,
|
||||
enabled = selection.selectedModels.isNotEmpty()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Delete selected",
|
||||
tint = if (selection.selectedModels.isNotEmpty())
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
// Batch-deletion action
|
||||
IconButton(onClick = onToggleDeleting) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.DeleteSweep,
|
||||
contentDescription = "Delete models"
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { selection.toggleAllSelection(false) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ClearAll,
|
||||
contentDescription = "Deselect all"
|
||||
)
|
||||
}
|
||||
// Sorting action
|
||||
IconButton(onClick = { sortingConfig.toggleMenu(true) }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Sort,
|
||||
contentDescription = "Sort models"
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { selection.toggleAllSelection(true) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SelectAll,
|
||||
contentDescription = "Select all"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
/* Default mode actions */
|
||||
|
||||
// Multi-selection action
|
||||
IconButton(onClick = { selection.toggleMode(true) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.DeleteSweep,
|
||||
contentDescription = "Delete models"
|
||||
)
|
||||
}
|
||||
|
||||
// Sorting action
|
||||
IconButton(onClick = { sorting.toggleMenu(true) }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Sort,
|
||||
contentDescription = "Sort models"
|
||||
)
|
||||
}
|
||||
|
||||
// Sorting dropdown menu
|
||||
DropdownMenu(
|
||||
expanded = sorting.isMenuVisible,
|
||||
onDismissRequest = { sorting.toggleMenu(false) }
|
||||
) {
|
||||
val sortOptions = listOf(
|
||||
Triple(
|
||||
ModelSortOrder.NAME_ASC,
|
||||
"Name (A-Z)",
|
||||
"Sort by name in ascending order"
|
||||
),
|
||||
Triple(
|
||||
ModelSortOrder.NAME_DESC,
|
||||
"Name (Z-A)",
|
||||
"Sort by name in descending order"
|
||||
),
|
||||
Triple(
|
||||
ModelSortOrder.SIZE_ASC,
|
||||
"Size (Smallest first)",
|
||||
"Sort by size in ascending order"
|
||||
),
|
||||
Triple(
|
||||
ModelSortOrder.SIZE_DESC,
|
||||
"Size (Largest first)",
|
||||
"Sort by size in descending order"
|
||||
),
|
||||
Triple(ModelSortOrder.LAST_USED, "Last used", "Sort by last used")
|
||||
)
|
||||
|
||||
sortOptions.forEach { (order, label, description) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
trailingIcon = {
|
||||
if (sorting.currentOrder == order)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "$description, selected"
|
||||
)
|
||||
},
|
||||
onClick = { sorting.selectOrder(order) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Filtering action
|
||||
IconButton(onClick = { filtering.toggleMenu(true) }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (filtering.isActive) Icons.Default.FilterAlt
|
||||
else Icons.Outlined.FilterAlt,
|
||||
contentDescription = "Filter models"
|
||||
)
|
||||
}
|
||||
|
||||
// Filter dropdown menu
|
||||
DropdownMenu(
|
||||
expanded = filtering.isMenuVisible,
|
||||
onDismissRequest = { filtering.toggleMenu(false) }
|
||||
) {
|
||||
Text(
|
||||
text = "Filter by",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
|
||||
filtering.filters.forEach { (filter, isEnabled) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(filter.displayName) },
|
||||
leadingIcon = {
|
||||
Checkbox(
|
||||
checked = isEnabled,
|
||||
onCheckedChange = null
|
||||
)
|
||||
},
|
||||
onClick = { filtering.onToggleFilter(filter, !isEnabled) }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
// Sorting dropdown menu
|
||||
DropdownMenu(
|
||||
expanded = sortingConfig.isMenuVisible,
|
||||
onDismissRequest = { sortingConfig.toggleMenu(false) }
|
||||
) {
|
||||
val sortOptions = listOf(
|
||||
Triple(
|
||||
ModelSortOrder.NAME_ASC,
|
||||
"Name (A-Z)",
|
||||
"Sort by name in ascending order"
|
||||
),
|
||||
Triple(
|
||||
ModelSortOrder.NAME_DESC,
|
||||
"Name (Z-A)",
|
||||
"Sort by name in descending order"
|
||||
),
|
||||
Triple(
|
||||
ModelSortOrder.SIZE_ASC,
|
||||
"Size (Smallest first)",
|
||||
"Sort by size in ascending order"
|
||||
),
|
||||
Triple(
|
||||
ModelSortOrder.SIZE_DESC,
|
||||
"Size (Largest first)",
|
||||
"Sort by size in descending order"
|
||||
),
|
||||
Triple(ModelSortOrder.LAST_USED, "Last used", "Sort by last used")
|
||||
)
|
||||
|
||||
sortOptions.forEach { (order, label, description) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("Clear filters") },
|
||||
text = { Text(label) },
|
||||
trailingIcon = {
|
||||
if (sortingConfig.currentOrder == order)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "$description, selected"
|
||||
)
|
||||
},
|
||||
onClick = { sortingConfig.selectOrder(order) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Filtering action
|
||||
IconButton(onClick = { filteringConfig.toggleMenu(true) }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (filteringConfig.isActive) Icons.Default.FilterAlt
|
||||
else Icons.Outlined.FilterAlt,
|
||||
contentDescription = "Filter models"
|
||||
)
|
||||
}
|
||||
|
||||
// Filter dropdown menu
|
||||
DropdownMenu(
|
||||
expanded = filteringConfig.isMenuVisible,
|
||||
onDismissRequest = { filteringConfig.toggleMenu(false) }
|
||||
) {
|
||||
Text(
|
||||
text = "Filter by",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
|
||||
filteringConfig.filters.forEach { (filter, isEnabled) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(filter.displayName) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.FilterAltOff,
|
||||
contentDescription = "Clear all filters"
|
||||
Checkbox(
|
||||
checked = isEnabled,
|
||||
onCheckedChange = null
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
filtering.onClearFilters()
|
||||
filtering.toggleMenu(false)
|
||||
}
|
||||
onClick = { filteringConfig.onToggleFilter(filter, !isEnabled) }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text("Clear filters") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.FilterAltOff,
|
||||
contentDescription = "Clear all filters"
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
filteringConfig.onClearFilters()
|
||||
filteringConfig.toggleMenu(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (selection.isActive) selection.toggleMode(false) else importing.toggleMenu(
|
||||
true
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
onClick = { importingConfig.toggleMenu(true) },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (selection.isActive) Icons.Default.Close else Icons.Default.Add,
|
||||
contentDescription = if (selection.isActive) "Exit selection mode" else "Add model"
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = "Add model"
|
||||
)
|
||||
}
|
||||
|
||||
// Add model dropdown menu
|
||||
DropdownMenu(
|
||||
expanded = importing.isMenuVisible,
|
||||
onDismissRequest = { importing.toggleMenu(false) }
|
||||
expanded = importingConfig.isMenuVisible,
|
||||
onDismissRequest = { importingConfig.toggleMenu(false) }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Import local model") },
|
||||
|
|
@ -214,7 +172,7 @@ fun ModelsManagementBottomBar(
|
|||
contentDescription = "Import a local model on the device"
|
||||
)
|
||||
},
|
||||
onClick = importing.importFromLocal
|
||||
onClick = importingConfig.importFromLocal
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Download from HuggingFace") },
|
||||
|
|
@ -226,7 +184,7 @@ fun ModelsManagementBottomBar(
|
|||
tint = Color.Unspecified,
|
||||
)
|
||||
},
|
||||
onClick = importing.importFromHuggingFace
|
||||
onClick = importingConfig.importFromHuggingFace
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.example.llama.ui.scaffold.topbar
|
|||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
|
|
@ -17,12 +18,22 @@ import androidx.compose.runtime.Composable
|
|||
fun DefaultTopBar(
|
||||
title: String,
|
||||
onNavigateBack: (() -> Unit)? = null,
|
||||
onQuit: (() -> Unit)? = null,
|
||||
onMenuOpen: (() -> Unit)? = null
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
when {
|
||||
onQuit != null -> {
|
||||
IconButton(onClick = onQuit) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Quit"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNavigateBack != null -> {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
package com.example.llama.ui.scaffold.topbar
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Memory
|
||||
|
|
@ -15,13 +12,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.llama.monitoring.MemoryMetrics
|
||||
import com.example.llama.monitoring.TemperatureMetrics
|
||||
|
|
@ -92,30 +89,32 @@ private fun MemoryIndicator(
|
|||
val availableGB = String.format(Locale.getDefault(), "%.1f", memoryUsage.availableGB)
|
||||
val totalGB = String.format(Locale.getDefault(), "%.1f", memoryUsage.totalGB)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(end = 12.dp).clickable(role = Role.Button) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
onClick = {
|
||||
onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
|
||||
message = "Free RAM available: $availableGB GB\nTotal RAM on your device: $totalGB GB",
|
||||
withDismissAction = true,
|
||||
))
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Memory,
|
||||
contentDescription = "RAM usage",
|
||||
tint = when {
|
||||
memoryUsage.availableGB < 1 -> MaterialTheme.colorScheme.error
|
||||
memoryUsage.availableGB < 3 -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Memory,
|
||||
contentDescription = "RAM usage",
|
||||
tint = when {
|
||||
memoryUsage.availableGB < 1 -> MaterialTheme.colorScheme.error
|
||||
memoryUsage.availableGB < 3 -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
text = "$availableGB / $totalGB GB",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
text = "$availableGB / $totalGB GB",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,36 +133,39 @@ private fun TemperatureIndicator(
|
|||
}
|
||||
val warningDismissible = temperatureMetrics.warningLevel == TemperatureWarningLevel.HIGH
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(end = 12.dp).clickable(role = Role.Button) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
onClick = {
|
||||
onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
|
||||
message = temperatureWarning,
|
||||
withDismissAction = warningDismissible,
|
||||
))
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = when (temperatureMetrics.warningLevel) {
|
||||
TemperatureWarningLevel.HIGH -> Icons.Default.WarningAmber
|
||||
else -> Icons.Default.Thermostat
|
||||
},
|
||||
contentDescription = "Device temperature",
|
||||
tint = when (temperatureMetrics.warningLevel) {
|
||||
TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error
|
||||
TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = when (temperatureMetrics.warningLevel) {
|
||||
TemperatureWarningLevel.HIGH -> Icons.Default.WarningAmber
|
||||
else -> Icons.Default.Thermostat
|
||||
},
|
||||
contentDescription = "Device temperature",
|
||||
tint = when (temperatureMetrics.warningLevel) {
|
||||
TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error
|
||||
TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
text = temperatureDisplay,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when (temperatureMetrics.warningLevel) {
|
||||
TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error
|
||||
TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
text = temperatureDisplay,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when (temperatureMetrics.warningLevel) {
|
||||
TemperatureWarningLevel.HIGH -> MaterialTheme.colorScheme.error
|
||||
TemperatureWarningLevel.MEDIUM -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
package com.example.llama.ui.scaffold.topbar
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.SdStorage
|
||||
|
|
@ -12,13 +9,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.llama.monitoring.StorageMetrics
|
||||
import com.example.llama.ui.scaffold.ScaffoldEvent
|
||||
|
|
@ -65,29 +62,31 @@ private fun StorageIndicator(
|
|||
val usedGb = String.format(Locale.getDefault(), "%.1f", storageMetrics.usedGB)
|
||||
val availableGb = String.format(Locale.getDefault(), "%.1f", storageMetrics.availableGB)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(end = 8.dp).clickable(role = Role.Button) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
onClick = {
|
||||
onScaffoldEvent(ScaffoldEvent.ShowSnackbar(
|
||||
message = "Your models occupy $usedGb GB storage\nRemaining free space available: $availableGb GB",
|
||||
withDismissAction = true,
|
||||
))
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SdStorage,
|
||||
contentDescription = "Storage",
|
||||
tint = when {
|
||||
storageMetrics.availableGB < 5.0f -> MaterialTheme.colorScheme.error
|
||||
storageMetrics.availableGB < 10.0f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SdStorage,
|
||||
contentDescription = "Storage",
|
||||
tint = when {
|
||||
storageMetrics.availableGB < 5.0f -> MaterialTheme.colorScheme.error
|
||||
storageMetrics.availableGB < 10.0f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
text = "$usedGb / $availableGb GB",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
text = "$usedGb / $availableGb GB",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package com.example.llama.ui.scaffold.topbar
|
|||
import com.example.llama.monitoring.MemoryMetrics
|
||||
import com.example.llama.monitoring.StorageMetrics
|
||||
import com.example.llama.monitoring.TemperatureMetrics
|
||||
import com.example.llama.ui.scaffold.ScaffoldEvent
|
||||
import com.example.llama.viewmodel.ModelScreenUiMode
|
||||
|
||||
/**
|
||||
* [TopAppBar] configurations
|
||||
|
|
@ -23,6 +25,19 @@ sealed class TopBarConfig {
|
|||
override val navigationIcon: NavigationIcon
|
||||
) : TopBarConfig()
|
||||
|
||||
// Model management top bar with a toggle to turn on/off manage mode
|
||||
data class ModelsBrowsing(
|
||||
override val title: String,
|
||||
override val navigationIcon: NavigationIcon,
|
||||
val onToggleMode: (ModelScreenUiMode) -> Unit,
|
||||
) : TopBarConfig()
|
||||
|
||||
// Model batch-deletion top bar with a toggle to turn on/off manage mode
|
||||
data class ModelsDeleting(
|
||||
override val title: String,
|
||||
override val navigationIcon: NavigationIcon,
|
||||
) : TopBarConfig()
|
||||
|
||||
// Performance monitoring top bar with RAM and optional temperature
|
||||
data class Performance(
|
||||
override val title: String,
|
||||
|
|
@ -32,7 +47,7 @@ sealed class TopBarConfig {
|
|||
) : TopBarConfig()
|
||||
|
||||
// Storage management top bar with used & total storage
|
||||
data class Storage(
|
||||
data class ModelsManagement(
|
||||
override val title: String,
|
||||
override val navigationIcon: NavigationIcon,
|
||||
val storageMetrics: StorageMetrics?
|
||||
|
|
@ -43,7 +58,8 @@ sealed class TopBarConfig {
|
|||
* Helper class for navigation icon configuration
|
||||
*/
|
||||
sealed class NavigationIcon {
|
||||
data class Back(val onNavigateBack: () -> Unit) : NavigationIcon()
|
||||
data class Menu(val onMenuOpen: () -> Unit) : NavigationIcon()
|
||||
object None : NavigationIcon()
|
||||
data class Back(val onNavigateBack: () -> Unit) : NavigationIcon()
|
||||
data class Quit(val onQuit: () -> Unit) : NavigationIcon()
|
||||
data object None : NavigationIcon()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ fun ConversationScreen(
|
|||
// UI states
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var isModelCardExpanded by remember { mutableStateOf(false) }
|
||||
var isModelCardExpanded by remember { mutableStateOf(true) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Track the actual rendered size of the last message bubble
|
||||
|
|
@ -234,7 +234,7 @@ fun ModelCardWithSystemPrompt(
|
|||
model: ModelInfo,
|
||||
loadingMetrics: ModelLoadingMetrics,
|
||||
systemPrompt: String?,
|
||||
isExpanded: Boolean = false,
|
||||
isExpanded: Boolean = true,
|
||||
onExpanded: ((Boolean) -> Unit)? = null,
|
||||
) = ModelCardCoreExpandable(model, isExpanded, onExpanded) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -67,7 +67,8 @@ import com.example.llama.viewmodel.ModelManagementState
|
|||
import com.example.llama.viewmodel.ModelManagementState.Deletion
|
||||
import com.example.llama.viewmodel.ModelManagementState.Download
|
||||
import com.example.llama.viewmodel.ModelManagementState.Importation
|
||||
import com.example.llama.viewmodel.ModelsManagementViewModel
|
||||
import com.example.llama.viewmodel.ModelScreenUiMode
|
||||
import com.example.llama.viewmodel.ModelsViewModel
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
|
|
@ -75,16 +76,16 @@ import java.util.Locale
|
|||
* Screen for managing LLM models (view, download, delete)
|
||||
*/
|
||||
@Composable
|
||||
fun ModelsManagementScreen(
|
||||
fun ModelsManagementAndDeletingScreen(
|
||||
isDeleting: Boolean,
|
||||
onScaffoldEvent: (ScaffoldEvent) -> Unit,
|
||||
viewModel: ModelsManagementViewModel,
|
||||
viewModel: ModelsViewModel,
|
||||
) {
|
||||
// Data: models
|
||||
val filteredModels by viewModel.filteredModels.collectAsState()
|
||||
|
||||
// Selection state
|
||||
val isMultiSelectionMode by viewModel.isMultiSelectionMode.collectAsState()
|
||||
val selectedModels by viewModel.selectedModels.collectAsState()
|
||||
val selectedModels by viewModel.selectedModelsToDelete.collectAsState()
|
||||
|
||||
// Filter state
|
||||
val activeFilters by viewModel.activeFilters.collectAsState()
|
||||
|
|
@ -96,19 +97,13 @@ fun ModelsManagementScreen(
|
|||
val managementState by viewModel.managementState.collectAsState()
|
||||
|
||||
// UI states
|
||||
var expandedModels = remember { mutableStateMapOf<String, ModelInfo>() }
|
||||
val expandedModels = remember { mutableStateMapOf<String, ModelInfo>() }
|
||||
|
||||
BackHandler(
|
||||
enabled = isMultiSelectionMode
|
||||
|| managementState is Importation.Importing
|
||||
enabled = managementState is Importation.Importing
|
||||
|| managementState is Deletion.Deleting
|
||||
) {
|
||||
if (isMultiSelectionMode) {
|
||||
// Exit selection mode if in selection mode
|
||||
viewModel.toggleSelectionMode(false)
|
||||
} else {
|
||||
/* Ignore back press while processing model management requests */
|
||||
}
|
||||
/* Ignore back press while processing model management requests */
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
|
|
@ -134,13 +129,13 @@ fun ModelsManagementScreen(
|
|||
) {
|
||||
items(items = filteredModels, key = { it.id }) { model ->
|
||||
val isSelected =
|
||||
if (isMultiSelectionMode) selectedModels.contains(model.id) else null
|
||||
if (isDeleting) selectedModels.contains(model.id) else null
|
||||
|
||||
ModelCardFullExpandable(
|
||||
model = model,
|
||||
isSelected = isSelected,
|
||||
onSelected = {
|
||||
if (isMultiSelectionMode) {
|
||||
if (isDeleting) {
|
||||
viewModel.toggleModelSelectionById(model.id)
|
||||
}
|
||||
},
|
||||
|
|
@ -285,7 +280,7 @@ fun ModelsManagementScreen(
|
|||
|
||||
is Deletion.Success -> {
|
||||
LaunchedEffect(state) {
|
||||
viewModel.toggleSelectionMode(false)
|
||||
viewModel.toggleMode(ModelScreenUiMode.MANAGING)
|
||||
|
||||
val count = state.models.size
|
||||
onScaffoldEvent(
|
||||
|
|
@ -641,6 +636,9 @@ private fun BatchDeleteConfirmationDialog(
|
|||
}
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textContentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onConfirm,
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -9,33 +9,43 @@ import android.content.IntentFilter
|
|||
import android.llama.cpp.gguf.InvalidFileFormatException
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.llama.data.model.ModelFilter
|
||||
import com.example.llama.data.model.ModelInfo
|
||||
import com.example.llama.data.model.ModelSortOrder
|
||||
import com.example.llama.data.model.filterBy
|
||||
import com.example.llama.data.model.queryBy
|
||||
import com.example.llama.data.model.sortByOrder
|
||||
import com.example.llama.data.source.remote.HuggingFaceDownloadInfo
|
||||
import com.example.llama.data.source.remote.HuggingFaceModel
|
||||
import com.example.llama.data.repo.InsufficientStorageException
|
||||
import com.example.llama.data.repo.ModelRepository
|
||||
import com.example.llama.data.source.remote.HuggingFaceDownloadInfo
|
||||
import com.example.llama.data.source.remote.HuggingFaceModel
|
||||
import com.example.llama.engine.InferenceService
|
||||
import com.example.llama.monitoring.PerformanceMonitor
|
||||
import com.example.llama.util.formatFileByteSize
|
||||
import com.example.llama.util.getFileNameFromUri
|
||||
import com.example.llama.util.getFileSizeFromUri
|
||||
import com.example.llama.viewmodel.ModelManagementState.Deletion
|
||||
import com.example.llama.viewmodel.ModelManagementState.Download
|
||||
import com.example.llama.viewmodel.ModelManagementState.Importation
|
||||
import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.FileNotFoundException
|
||||
|
|
@ -43,70 +53,81 @@ import java.io.IOException
|
|||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@HiltViewModel
|
||||
class ModelsManagementViewModel @Inject constructor(
|
||||
class ModelsViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val modelRepository: ModelRepository
|
||||
private val modelRepository: ModelRepository,
|
||||
private val performanceMonitor: PerformanceMonitor,
|
||||
private val inferenceService: InferenceService,
|
||||
) : ViewModel() {
|
||||
|
||||
// Data: models
|
||||
private val _filteredModels = MutableStateFlow<List<ModelInfo>>(emptyList())
|
||||
val filteredModels: StateFlow<List<ModelInfo>> = _filteredModels.asStateFlow()
|
||||
// UI state: model management mode
|
||||
private val _modelScreenUiMode = MutableStateFlow(ModelScreenUiMode.BROWSING)
|
||||
val modelScreenUiMode = _modelScreenUiMode.asStateFlow()
|
||||
|
||||
// UI state: multi-selection mode
|
||||
private val _isMultiSelectionMode = MutableStateFlow(false)
|
||||
val isMultiSelectionMode: StateFlow<Boolean> = _isMultiSelectionMode.asStateFlow()
|
||||
|
||||
fun toggleSelectionMode(enabled: Boolean) {
|
||||
_isMultiSelectionMode.value = enabled
|
||||
if (!enabled) {
|
||||
toggleAllSelection(selectAll = false)
|
||||
}
|
||||
}
|
||||
|
||||
// UI state: models selected in multi-selection
|
||||
private val _selectedModels = MutableStateFlow<Map<String, ModelInfo>>(emptyMap())
|
||||
val selectedModels: StateFlow<Map<String, ModelInfo>> = _selectedModels.asStateFlow()
|
||||
|
||||
fun toggleModelSelectionById(modelId: String) {
|
||||
val current = _selectedModels.value.toMutableMap()
|
||||
val model = _filteredModels.value.find { it.id == modelId }
|
||||
|
||||
if (model != null) {
|
||||
if (current.containsKey(modelId)) {
|
||||
current.remove(modelId)
|
||||
} else {
|
||||
current[modelId] = model
|
||||
fun toggleMode(newMode: ModelScreenUiMode): Boolean {
|
||||
val oldMode = _modelScreenUiMode.value
|
||||
when (oldMode) {
|
||||
ModelScreenUiMode.BROWSING -> {
|
||||
when (newMode) {
|
||||
ModelScreenUiMode.SEARCHING -> {
|
||||
resetPreselection()
|
||||
}
|
||||
ModelScreenUiMode.MANAGING -> {
|
||||
resetPreselection()
|
||||
}
|
||||
ModelScreenUiMode.DELETING -> { return false }
|
||||
else -> { /* No-op */ }
|
||||
}
|
||||
}
|
||||
ModelScreenUiMode.SEARCHING -> {
|
||||
when (newMode) {
|
||||
ModelScreenUiMode.BROWSING -> {
|
||||
searchFieldState.clearText()
|
||||
}
|
||||
else -> { return false }
|
||||
}
|
||||
}
|
||||
ModelScreenUiMode.MANAGING -> {
|
||||
when (newMode) {
|
||||
ModelScreenUiMode.SEARCHING -> { return false }
|
||||
else -> { /* No-op */ }
|
||||
}
|
||||
}
|
||||
ModelScreenUiMode.DELETING -> {
|
||||
when (newMode) {
|
||||
ModelScreenUiMode.BROWSING, ModelScreenUiMode.SEARCHING -> { return false }
|
||||
else -> { /* No-op */ }
|
||||
}
|
||||
}
|
||||
_selectedModels.value = current
|
||||
}
|
||||
_modelScreenUiMode.value = newMode
|
||||
return true
|
||||
}
|
||||
|
||||
fun toggleAllSelection(selectAll: Boolean) {
|
||||
if (selectAll) {
|
||||
_selectedModels.value = _filteredModels.value.associateBy { it.id }
|
||||
} else {
|
||||
_selectedModels.value = emptyMap()
|
||||
}
|
||||
}
|
||||
// UI state: search mode
|
||||
val searchFieldState = TextFieldState()
|
||||
|
||||
// UI state: sort menu
|
||||
private val _sortOrder = MutableStateFlow(ModelSortOrder.NAME_ASC)
|
||||
val sortOrder: StateFlow<ModelSortOrder> = _sortOrder.asStateFlow()
|
||||
private val _sortOrder = MutableStateFlow(ModelSortOrder.LAST_USED)
|
||||
val sortOrder = _sortOrder.asStateFlow()
|
||||
|
||||
fun setSortOrder(order: ModelSortOrder) {
|
||||
_sortOrder.value = order
|
||||
}
|
||||
|
||||
private val _showSortMenu = MutableStateFlow(false)
|
||||
val showSortMenu: StateFlow<Boolean> = _showSortMenu.asStateFlow()
|
||||
val showSortMenu = _showSortMenu.asStateFlow()
|
||||
|
||||
fun toggleSortMenu(show: Boolean) {
|
||||
_showSortMenu.value = show
|
||||
fun toggleSortMenu(visible: Boolean) {
|
||||
_showSortMenu.value = visible
|
||||
}
|
||||
|
||||
// UI state: filters
|
||||
// UI state: filter menu
|
||||
private val _activeFilters = MutableStateFlow<Map<ModelFilter, Boolean>>(
|
||||
ModelFilter.ALL_FILTERS.associateWith { false }
|
||||
)
|
||||
|
|
@ -127,12 +148,69 @@ class ModelsManagementViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private val _showFilterMenu = MutableStateFlow(false)
|
||||
val showFilterMenu: StateFlow<Boolean> = _showFilterMenu.asStateFlow()
|
||||
val showFilterMenu = _showFilterMenu.asStateFlow()
|
||||
|
||||
fun toggleFilterMenu(visible: Boolean) {
|
||||
_showFilterMenu.value = visible
|
||||
}
|
||||
|
||||
// Data: filtered & sorted models
|
||||
private val _filteredModels = MutableStateFlow<List<ModelInfo>>(emptyList())
|
||||
val filteredModels = _filteredModels.asStateFlow()
|
||||
|
||||
// Data: queried models
|
||||
private val _queryResults = MutableStateFlow<List<ModelInfo>>(emptyList())
|
||||
val queryResults = _queryResults.asStateFlow()
|
||||
|
||||
// Data: pre-selected model in expansion mode
|
||||
private val _preselectedModelToRun = MutableStateFlow<ModelInfo?>(null)
|
||||
private val _showRamWarning = MutableStateFlow(false)
|
||||
val preselectedModelToRun = combine(
|
||||
_preselectedModelToRun,
|
||||
performanceMonitor.monitorMemoryUsage(),
|
||||
_showRamWarning,
|
||||
) { model, memory, show ->
|
||||
if (model == null) {
|
||||
null
|
||||
} else {
|
||||
if (memory.availableMem >= model.sizeInBytes + RAM_LOAD_MODEL_BUFFER_BYTES) {
|
||||
PreselectedModelToRun(model, null)
|
||||
} else {
|
||||
PreselectedModelToRun(model, RamWarning(model.sizeInBytes, memory.availableMem, show))
|
||||
}
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
|
||||
initialValue = null
|
||||
)
|
||||
|
||||
// UI state: models selected in deleting mode
|
||||
private val _selectedModelsToDelete = MutableStateFlow<Map<String, ModelInfo>>(emptyMap())
|
||||
val selectedModelsToDelete: StateFlow<Map<String, ModelInfo>> = _selectedModelsToDelete.asStateFlow()
|
||||
|
||||
fun toggleModelSelectionById(modelId: String) {
|
||||
val current = _selectedModelsToDelete.value.toMutableMap()
|
||||
val model = _filteredModels.value.find { it.id == modelId }
|
||||
|
||||
if (model != null) {
|
||||
if (current.containsKey(modelId)) {
|
||||
current.remove(modelId)
|
||||
} else {
|
||||
current[modelId] = model
|
||||
}
|
||||
_selectedModelsToDelete.value = current
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleAllSelectedModelsToDelete(selectAll: Boolean) {
|
||||
if (selectAll) {
|
||||
_selectedModelsToDelete.value = _filteredModels.value.associateBy { it.id }
|
||||
} else {
|
||||
_selectedModelsToDelete.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
// UI state: import menu
|
||||
private val _showImportModelMenu = MutableStateFlow(false)
|
||||
val showImportModelMenu: StateFlow<Boolean> = _showImportModelMenu.asStateFlow()
|
||||
|
|
@ -156,6 +234,17 @@ class ModelsManagementViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// Internal state
|
||||
private val _managementState = MutableStateFlow<ModelManagementState>(ModelManagementState.Idle)
|
||||
val managementState: StateFlow<ModelManagementState> = _managementState.asStateFlow()
|
||||
|
||||
fun resetManagementState() {
|
||||
huggingFaceQueryJob?.let {
|
||||
if (it.isActive) { it.cancel() }
|
||||
}
|
||||
_managementState.value = ModelManagementState.Idle
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
|
|
@ -169,21 +258,83 @@ class ModelsManagementViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
modelRepository.getModels(),
|
||||
snapshotFlow { searchFieldState.text }.debounce(QUERY_DEBOUNCE_TIMEOUT_MS)
|
||||
) { models, query ->
|
||||
if (query.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
models.queryBy(query.toString()).sortedBy { it.dateLastUsed ?: it.dateAdded }
|
||||
}
|
||||
}.collectLatest {
|
||||
_queryResults.value = it
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||
context.registerReceiver(downloadReceiver, filter, RECEIVER_EXPORTED)
|
||||
}
|
||||
|
||||
// Internal state
|
||||
private val _managementState = MutableStateFlow<ModelManagementState>(ModelManagementState.Idle)
|
||||
val managementState: StateFlow<ModelManagementState> = _managementState.asStateFlow()
|
||||
|
||||
fun resetManagementState() {
|
||||
huggingFaceQueryJob?.let {
|
||||
if (it.isActive) { it.cancel() }
|
||||
}
|
||||
_managementState.value = ModelManagementState.Idle
|
||||
/**
|
||||
* Pre-select a model to expand its details and show Run FAB
|
||||
*/
|
||||
fun preselectModel(modelInfo: ModelInfo, preselected: Boolean) {
|
||||
_preselectedModelToRun.value = if (preselected) modelInfo else null
|
||||
_showRamWarning.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset preselected model to none (before navigating away)
|
||||
*/
|
||||
fun resetPreselection() {
|
||||
_preselectedModelToRun.value = null
|
||||
_showRamWarning.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the currently pre-selected model
|
||||
*
|
||||
* @return True if RAM enough, otherwise False.
|
||||
*/
|
||||
fun selectModel(preselectedModelToRun: PreselectedModelToRun) =
|
||||
when (preselectedModelToRun.ramWarning?.showing) {
|
||||
null -> {
|
||||
inferenceService.setCurrentModel(preselectedModelToRun.modelInfo)
|
||||
true
|
||||
}
|
||||
false -> {
|
||||
_showRamWarning.value = true
|
||||
false
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the RAM warnings
|
||||
*/
|
||||
fun dismissRamWarning() {
|
||||
_showRamWarning.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge RAM warnings and confirm currently pre-selected model
|
||||
*
|
||||
* @return True if confirmed, otherwise False.
|
||||
*/
|
||||
fun confirmSelectedModel(modelInfo: ModelInfo, ramWarning: RamWarning): Boolean =
|
||||
if (ramWarning.showing) {
|
||||
inferenceService.setCurrentModel(modelInfo)
|
||||
_showRamWarning.value = false
|
||||
|
||||
resetPreselection()
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* First show confirmation instead of starting import local file immediately
|
||||
*/
|
||||
|
|
@ -199,6 +350,7 @@ class ModelsManagementViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Import a local model file from device storage while updating UI states with realtime progress
|
||||
*/
|
||||
|
|
@ -359,9 +511,10 @@ class ModelsManagementViewModel @Inject constructor(
|
|||
_managementState.value = Deletion.Deleting(deleted.toFloat() / total, models)
|
||||
}
|
||||
_managementState.value = Deletion.Success(models.values.toList())
|
||||
toggleAllSelectedModelsToDelete(false)
|
||||
|
||||
// Reset state after a delay
|
||||
delay(SUCCESS_RESET_TIMEOUT_MS)
|
||||
delay(DELETE_SUCCESS_RESET_TIMEOUT_MS)
|
||||
_managementState.value = ModelManagementState.Idle
|
||||
} catch (e: Exception) {
|
||||
_managementState.value = Deletion.Error(
|
||||
|
|
@ -371,12 +524,35 @@ class ModelsManagementViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = ModelsManagementViewModel::class.java.simpleName
|
||||
private val TAG = ModelsViewModel::class.java.simpleName
|
||||
|
||||
private const val SUCCESS_RESET_TIMEOUT_MS = 1000L
|
||||
private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
|
||||
private const val QUERY_DEBOUNCE_TIMEOUT_MS = 500L
|
||||
|
||||
private const val DELETE_SUCCESS_RESET_TIMEOUT_MS = 1000L
|
||||
|
||||
private const val RAM_LOAD_MODEL_BUFFER_BYTES = 300 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
enum class ModelScreenUiMode {
|
||||
BROWSING,
|
||||
SEARCHING,
|
||||
MANAGING,
|
||||
DELETING
|
||||
}
|
||||
|
||||
data class PreselectedModelToRun(
|
||||
val modelInfo: ModelInfo,
|
||||
val ramWarning: RamWarning?,
|
||||
) {
|
||||
data class RamWarning(
|
||||
val requiredRam: Long,
|
||||
val availableRam: Long,
|
||||
val showing: Boolean,
|
||||
)
|
||||
}
|
||||
|
||||
sealed class ModelManagementState {
|
||||
object Idle : ModelManagementState()
|
||||
|
||||
Loading…
Reference in New Issue