diff --git a/examples/llama.android/app/src/main/java/com/example/llama/MainActivity.kt b/examples/llama.android/app/src/main/java/com/example/llama/MainActivity.kt index 2a55b31033..647c49c911 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/MainActivity.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/MainActivity.kt @@ -53,9 +53,10 @@ 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.ModelScreenUiMode +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,10 +86,10 @@ fun AppContent( settingsViewModel: SettingsViewModel, mainViewModel: MainViewModel = hiltViewModel(), modelsViewModel: ModelsViewModel = hiltViewModel(), + modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(), modelLoadingViewModel: ModelLoadingViewModel = hiltViewModel(), benchmarkViewModel: BenchmarkViewModel = hiltViewModel(), conversationViewModel: ConversationViewModel = hiltViewModel(), -// modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(), ) { val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -191,19 +192,20 @@ fun AppContent( // Model selection screen currentRoute == AppDestinations.MODELS_ROUTE -> { // Collect states for bottom bar + val filteredModels by modelsViewModel.filteredModels.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() + val selectedModelsToDelete by modelsManagementViewModel.selectedModelsToDelete.collectAsState() + val showImportModelMenu by modelsManagementViewModel.showImportModelMenu.collectAsState() // Create file launcher for importing local models val fileLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument() - ) { uri -> uri?.let { modelsViewModel.importLocalModelFileSelected(it) } } + ) { uri -> uri?.let { modelsManagementViewModel.importLocalModelFileSelected(it) } } ScaffoldConfig( topBarConfig = @@ -231,6 +233,7 @@ fun AppContent( TopBarConfig.ModelsDeleting( title = "Deleting models", navigationIcon = NavigationIcon.Quit { + modelsManagementViewModel.resetManagementState() modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING) }, ) @@ -309,14 +312,14 @@ fun AppContent( ), importing = BottomBarConfig.Models.Management.ImportConfig( isMenuVisible = showImportModelMenu, - toggleMenu = { show -> modelsViewModel.toggleImportMenu(show) }, + toggleMenu = { show -> modelsManagementViewModel.toggleImportMenu(show) }, importFromLocal = { fileLauncher.launch(arrayOf("application/octet-stream", "*/*")) - modelsViewModel.toggleImportMenu(false) + modelsManagementViewModel.toggleImportMenu(false) }, importFromHuggingFace = { - modelsViewModel.queryModelsFromHuggingFace() - modelsViewModel.toggleImportMenu(false) + modelsManagementViewModel.queryModelsFromHuggingFace() + modelsManagementViewModel.toggleImportMenu(false) } ), onToggleDeleting = { @@ -330,11 +333,16 @@ fun AppContent( modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING) }, selectedModels = selectedModelsToDelete, - toggleAllSelection = { modelsViewModel.toggleAllSelectedModelsToDelete(it) }, + selectAllFilteredModels = { + modelsManagementViewModel.selectAllFilteredModelsToDelete(filteredModels) + }, + clearAllSelectedModels = { + modelsManagementViewModel.clearAllSelectedModelsToDelete() + }, deleteSelected = { selectedModelsToDelete.let { if (it.isNotEmpty()) { - modelsViewModel.batchDeletionClicked(it) + modelsManagementViewModel.batchDeletionClicked(it) } } }, @@ -464,7 +472,8 @@ fun AppContent( } }, onScaffoldEvent = handleScaffoldEvent, - viewModel = modelsViewModel + modelsViewModel = modelsViewModel, + managementViewModel = modelsManagementViewModel, ) } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/AppScaffold.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/AppScaffold.kt index 98ad2c5cd3..04268b75c0 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/AppScaffold.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/AppScaffold.kt @@ -1,6 +1,7 @@ package com.example.llama.ui.scaffold import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost @@ -77,6 +78,8 @@ fun AppScaffold( is TopBarConfig.ModelsDeleting -> DefaultTopBar( title = topBarconfig.title, + titleColor = MaterialTheme.colorScheme.error, + navigationIconTint = MaterialTheme.colorScheme.error, onQuit = topBarconfig.navigationIcon.quitAction ) diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/BottomBarConfig.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/BottomBarConfig.kt index 73eea87b67..6adb67fb85 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/BottomBarConfig.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/BottomBarConfig.kt @@ -78,7 +78,8 @@ sealed class BottomBarConfig { data class Deleting( val onQuitDeleting: () -> Unit, val selectedModels: Map, - val toggleAllSelection: (Boolean) -> Unit, + val selectAllFilteredModels: () -> Unit, + val clearAllSelectedModels: () -> Unit, val deleteSelected: () -> Unit ) : BottomBarConfig() diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/ModelsDeletingBottomBar.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/ModelsDeletingBottomBar.kt index dd8426d9b8..f70414f4f0 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/ModelsDeletingBottomBar.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/bottombar/ModelsDeletingBottomBar.kt @@ -23,14 +23,14 @@ fun ModelsDeletingBottomBar( ) { BottomAppBar( actions = { - IconButton(onClick = { deleting.toggleAllSelection(false) }) { + IconButton(onClick = { deleting.clearAllSelectedModels() }) { Icon( imageVector = Icons.Default.ClearAll, contentDescription = "Deselect all" ) } - IconButton(onClick = { deleting.toggleAllSelection(true) }) { + IconButton(onClick = { deleting.selectAllFilteredModels() }) { Icon( imageVector = Icons.Default.SelectAll, contentDescription = "Select all" diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/topbar/DefaultTopBar.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/topbar/DefaultTopBar.kt index 95d02409a1..c55cead367 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/topbar/DefaultTopBar.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/scaffold/topbar/DefaultTopBar.kt @@ -12,24 +12,30 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color @OptIn(ExperimentalMaterial3Api::class) @Composable fun DefaultTopBar( title: String, + titleColor: Color = Color.Unspecified, + navigationIconTint: Color = Color.Unspecified, onNavigateBack: (() -> Unit)? = null, onQuit: (() -> Unit)? = null, onMenuOpen: (() -> Unit)? = null ) { TopAppBar( - title = { Text(title) }, + title = { + Text(text = title, color = titleColor) + }, navigationIcon = { when { onQuit != null -> { IconButton(onClick = onQuit) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Quit" + contentDescription = "Quit", + tint = navigationIconTint ) } } @@ -38,7 +44,8 @@ fun DefaultTopBar( IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + contentDescription = "Back", + tint = navigationIconTint ) } } @@ -47,7 +54,8 @@ fun DefaultTopBar( IconButton(onClick = onMenuOpen) { Icon( imageVector = Icons.Default.Menu, - contentDescription = "Menu" + contentDescription = "Menu", + tint = navigationIconTint ) } } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsBrowsingScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsBrowsingScreen.kt index 6d1b005dea..1cb6ff1738 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsBrowsingScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsBrowsingScreen.kt @@ -9,33 +9,23 @@ 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.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.viewmodel.ModelsViewModel +import com.example.llama.viewmodel.PreselectedModelToRun @Composable fun ModelsBrowsingScreen( + filteredModels: List, + activeFiltersCount: Int, + preselection: PreselectedModelToRun?, 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) @@ -63,7 +53,6 @@ fun ModelsBrowsingScreen( } } - @Composable private fun EmptyModelsView( activeFiltersCount: Int, diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsManagementAndDeletingScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsManagementAndDeletingScreen.kt index 3728bd48ea..66d77564bf 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsManagementAndDeletingScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsManagementAndDeletingScreen.kt @@ -39,7 +39,6 @@ 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.mutableStateMapOf import androidx.compose.runtime.mutableStateOf @@ -55,6 +54,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.core.net.toUri +import com.example.llama.data.model.ModelFilter import com.example.llama.data.model.ModelInfo import com.example.llama.data.source.remote.HuggingFaceModel import com.example.llama.ui.components.InfoAction @@ -68,6 +68,7 @@ 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.ModelScreenUiMode +import com.example.llama.viewmodel.ModelsManagementViewModel import com.example.llama.viewmodel.ModelsViewModel import java.text.SimpleDateFormat import java.util.Locale @@ -77,24 +78,18 @@ import java.util.Locale */ @Composable fun ModelsManagementAndDeletingScreen( + filteredModels: List, + activeFiltersCount: Int, isDeleting: Boolean, onScaffoldEvent: (ScaffoldEvent) -> Unit, - viewModel: ModelsViewModel, + modelsViewModel: ModelsViewModel, + managementViewModel: ModelsManagementViewModel, ) { - // Data: models - val filteredModels by viewModel.filteredModels.collectAsState() - // Selection state - val selectedModels by viewModel.selectedModelsToDelete.collectAsState() - - // Filter state - val activeFilters by viewModel.activeFilters.collectAsState() - val activeFiltersCount by remember(activeFilters) { - derivedStateOf { activeFilters.count { it.value } } - } + val selectedModels by managementViewModel.selectedModelsToDelete.collectAsState() // Model management state - val managementState by viewModel.managementState.collectAsState() + val managementState by managementViewModel.managementState.collectAsState() // UI states val expandedModels = remember { mutableStateMapOf() } @@ -136,7 +131,7 @@ fun ModelsManagementAndDeletingScreen( isSelected = isSelected, onSelected = { if (isDeleting) { - viewModel.toggleModelSelectionById(model.id) + managementViewModel.toggleModelSelectionById(filteredModels, model.id) } }, isExpanded = expandedModels.contains(model.id), @@ -161,11 +156,11 @@ fun ModelsManagementAndDeletingScreen( isImporting = false, progress = 0.0f, onConfirm = { - viewModel.importLocalModelFileConfirmed( + managementViewModel.importLocalModelFileConfirmed( state.uri, state.fileName, state.fileSize ) }, - onCancel = { viewModel.resetManagementState() } + onCancel = { managementViewModel.resetManagementState() } ) } @@ -177,7 +172,7 @@ fun ModelsManagementAndDeletingScreen( isCancelling = state.isCancelling, progress = state.progress, onConfirm = {}, - onCancel = { viewModel.cancelOngoingLocalModelImport() }, + onCancel = { managementViewModel.cancelOngoingLocalModelImport() }, ) } @@ -186,7 +181,7 @@ fun ModelsManagementAndDeletingScreen( title = "Import Failed", message = state.message, learnMoreUrl = state.learnMoreUrl, - onDismiss = { viewModel.resetManagementState() } + onDismiss = { managementViewModel.resetManagementState() } ) } @@ -198,21 +193,21 @@ fun ModelsManagementAndDeletingScreen( ) ) - viewModel.resetManagementState() + managementViewModel.resetManagementState() } } is Download.Querying -> { ImportFromHuggingFaceDialog( - onCancel = { viewModel.resetManagementState() } + onCancel = { managementViewModel.resetManagementState() } ) } is Download.Ready -> { ImportFromHuggingFaceDialog( models = state.models, - onConfirm = { viewModel.downloadHuggingFaceModelConfirmed(it) }, - onCancel = { viewModel.resetManagementState() } + onConfirm = { managementViewModel.downloadHuggingFaceModelConfirmed(it) }, + onCancel = { managementViewModel.resetManagementState() } ) } @@ -225,7 +220,7 @@ fun ModelsManagementAndDeletingScreen( ) ) - viewModel.resetManagementState() + managementViewModel.resetManagementState() } } @@ -236,11 +231,11 @@ fun ModelsManagementAndDeletingScreen( isImporting = false, progress = 0.0f, onConfirm = { - viewModel.importLocalModelFileConfirmed( + managementViewModel.importLocalModelFileConfirmed( state.uri, state.fileName, state.fileSize ) }, - onCancel = { viewModel.resetManagementState() } + onCancel = { managementViewModel.resetManagementState() } ) } @@ -248,15 +243,15 @@ fun ModelsManagementAndDeletingScreen( ErrorDialog( title = "Download Failed", message = state.message, - onDismiss = { viewModel.resetManagementState() } + onDismiss = { managementViewModel.resetManagementState() } ) } is Deletion.Confirming -> { BatchDeleteConfirmationDialog( count = state.models.size, - onConfirm = { viewModel.deleteModels(state.models) }, - onDismiss = { viewModel.resetManagementState() }, + onConfirm = { managementViewModel.deleteModels(state.models) }, + onDismiss = { managementViewModel.resetManagementState() }, isDeleting = false ) } @@ -274,19 +269,20 @@ fun ModelsManagementAndDeletingScreen( ErrorDialog( title = "Deletion Failed", message = state.message, - onDismiss = { viewModel.resetManagementState() } + onDismiss = { managementViewModel.resetManagementState() } ) } is Deletion.Success -> { LaunchedEffect(state) { - viewModel.toggleMode(ModelScreenUiMode.MANAGING) + modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING) val count = state.models.size onScaffoldEvent( ScaffoldEvent.ShowSnackbar( message = "Deleted $count ${if (count > 1) "models" else "model"}.", - duration = SnackbarDuration.Long + withDismissAction = true, + duration = SnackbarDuration.Long, ) ) } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsScreen.kt index e4fd990edc..f40df3de0b 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsScreen.kt @@ -13,13 +13,16 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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 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.ModelsManagementViewModel import com.example.llama.viewmodel.ModelsViewModel import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning @@ -29,31 +32,39 @@ fun ModelsScreen( onManageModelsClicked: () -> Unit, onConfirmSelection: (ModelInfo, RamWarning) -> Unit, onScaffoldEvent: (ScaffoldEvent) -> Unit, - viewModel: ModelsViewModel, + modelsViewModel: ModelsViewModel, + managementViewModel: ModelsManagementViewModel, ) { // Data - val preselection by viewModel.preselectedModelToRun.collectAsState() + val filteredModels by modelsViewModel.filteredModels.collectAsState() + val preselection by modelsViewModel.preselectedModelToRun.collectAsState() + + // UI states: Filter + val activeFilters by modelsViewModel.activeFilters.collectAsState() + val activeFiltersCount by remember(activeFilters) { + derivedStateOf { activeFilters.count { it.value } } + } // UI states - val currentMode by viewModel.modelScreenUiMode.collectAsState() + val currentMode by modelsViewModel.modelScreenUiMode.collectAsState() // Handle back button press BackHandler { when (currentMode) { ModelScreenUiMode.BROWSING -> { if (preselection != null) { - viewModel.resetPreselection() + modelsViewModel.resetPreselection() } } ModelScreenUiMode.SEARCHING -> { - viewModel.toggleMode(ModelScreenUiMode.BROWSING) + modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING) } ModelScreenUiMode.MANAGING -> { - viewModel.toggleMode(ModelScreenUiMode.BROWSING) + modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING) } ModelScreenUiMode.DELETING -> { - viewModel.toggleAllSelectedModelsToDelete(false) - viewModel.toggleMode(ModelScreenUiMode.MANAGING) + managementViewModel.clearAllSelectedModelsToDelete() + modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING) } } } @@ -64,16 +75,25 @@ fun ModelsScreen( when (currentMode) { ModelScreenUiMode.BROWSING -> ModelsBrowsingScreen( + filteredModels = filteredModels, + preselection = preselection, onManageModelsClicked = { /* TODO-han.yin */ }, - viewModel = viewModel + activeFiltersCount = activeFiltersCount, + viewModel = modelsViewModel, ) ModelScreenUiMode.SEARCHING -> - ModelsSearchingScreen(viewModel = viewModel) + ModelsSearchingScreen( + preselection = preselection, + viewModel = modelsViewModel + ) ModelScreenUiMode.MANAGING, ModelScreenUiMode.DELETING -> ModelsManagementAndDeletingScreen( + filteredModels = filteredModels, isDeleting = currentMode == ModelScreenUiMode.DELETING, onScaffoldEvent = onScaffoldEvent, - viewModel = viewModel + activeFiltersCount = activeFiltersCount, + modelsViewModel = modelsViewModel, + managementViewModel = managementViewModel, ) } @@ -83,7 +103,7 @@ fun ModelsScreen( if (warning.showing) { RamErrorDialog( warning, - onDismiss = { viewModel.dismissRamWarning() }, + onDismiss = { modelsViewModel.dismissRamWarning() }, onConfirm = { onConfirmSelection(it.modelInfo, warning) } ) } diff --git a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsSearchingScreen.kt b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsSearchingScreen.kt index 1f96058419..792d300cc6 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsSearchingScreen.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/ui/screens/ModelsSearchingScreen.kt @@ -39,15 +39,14 @@ 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 +import com.example.llama.viewmodel.PreselectedModelToRun @ExperimentalMaterial3Api @Composable fun ModelsSearchingScreen( - + preselection: PreselectedModelToRun?, viewModel: ModelsViewModel, ) { - val preselection by viewModel.preselectedModelToRun.collectAsState() - // Query states val textFieldState = viewModel.searchFieldState val searchQuery by remember(textFieldState) { @@ -68,7 +67,7 @@ fun ModelsSearchingScreen( } } - + // TODO-han.yin: remove after validation // LaunchedEffect (isSearchActive) { // if (isSearchActive) { // toggleSearchFocusAndIme(true) diff --git a/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsManagementViewModel.kt b/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsManagementViewModel.kt new file mode 100644 index 0000000000..01de4990d1 --- /dev/null +++ b/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsManagementViewModel.kt @@ -0,0 +1,335 @@ +package com.example.llama.viewmodel + +import android.app.DownloadManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Context.RECEIVER_EXPORTED +import android.content.Intent +import android.content.IntentFilter +import android.llama.cpp.gguf.InvalidFileFormatException +import android.net.Uri +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.llama.data.model.ModelInfo +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.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 dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.io.FileNotFoundException +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +@HiltViewModel +class ModelsManagementViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val modelRepository: ModelRepository, +) : ViewModel() { + // UI state: models selected to be batch-deleted + private val _selectedModelsToDelete = MutableStateFlow>(emptyMap()) + val selectedModelsToDelete: StateFlow> = _selectedModelsToDelete.asStateFlow() + + fun toggleModelSelectionById(filteredModels: List, modelId: String) { + val current = _selectedModelsToDelete.value.toMutableMap() + val model = filteredModels.find { it.id == modelId } + + if (model != null) { + if (current.containsKey(modelId)) { + current.remove(modelId) + } else { + current[modelId] = model + } + _selectedModelsToDelete.value = current + } + } + + fun selectAllFilteredModelsToDelete(filteredModels: List) { + _selectedModelsToDelete.value = filteredModels.associateBy { it.id } + } + + fun clearAllSelectedModelsToDelete() { + _selectedModelsToDelete.value = emptyMap() + } + + // UI state: import menu + private val _showImportModelMenu = MutableStateFlow(false) + val showImportModelMenu: StateFlow = _showImportModelMenu.asStateFlow() + + fun toggleImportMenu(show: Boolean) { + _showImportModelMenu.value = show + } + + // HuggingFace: ongoing query jobs + private var huggingFaceQueryJob: Job? = null + + // HuggingFace: Ongoing download jobs + private val activeDownloads = mutableMapOf() + private val downloadReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1).let { id -> + if (id in activeDownloads) { + handleDownloadComplete(id) + } + } + } + } + + // Internal state + private val _managementState = MutableStateFlow(ModelManagementState.Idle) + val managementState: StateFlow = _managementState.asStateFlow() + + fun resetManagementState() { + huggingFaceQueryJob?.let { + if (it.isActive) { it.cancel() } + } + clearAllSelectedModelsToDelete() + _managementState.value = ModelManagementState.Idle + } + + init { + context.registerReceiver( + downloadReceiver, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), + RECEIVER_EXPORTED + ) + } + + /** + * First show confirmation instead of starting import local file immediately + */ + fun importLocalModelFileSelected(uri: Uri) = viewModelScope.launch { + try { + val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A") + val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A") + _managementState.value = Importation.Confirming(uri, fileName, fileSize) + } catch (e: Exception) { + _managementState.value = Importation.Error( + message = e.message ?: "Unknown error preparing import" + ) + } + } + + + /** + * Import a local model file from device storage while updating UI states with realtime progress + */ + fun importLocalModelFileConfirmed(uri: Uri, fileName: String, fileSize: Long) = viewModelScope.launch { + try { + _managementState.value = Importation.Importing(0f, fileName, fileSize) + val model = modelRepository.importModel(uri, fileName, fileSize) { progress -> + _managementState.value = Importation.Importing(progress, fileName, fileSize) + } + _managementState.value = Importation.Success(model) + } catch (_: InvalidFileFormatException) { + _managementState.value = Importation.Error( + message = "Not a valid GGUF model!", + learnMoreUrl = "https://huggingface.co/docs/hub/en/gguf", + ) + } catch (e: InsufficientStorageException) { + _managementState.value = Importation.Error( + message = e.message ?: "Insufficient storage space to import $fileName", + learnMoreUrl = "https://support.google.com/android/answer/7431795?hl=en", + ) + } catch (e: Exception) { + Log.e(TAG, "Unknown exception importing $fileName", e) + _managementState.value = Importation.Error( + message = e.message ?: "Unknown error importing $fileName", + ) + } + } + + fun cancelOngoingLocalModelImport() = viewModelScope.launch { + viewModelScope.launch { + // First update UI to show we're attempting to cancel + _managementState.update { current -> + if (current is Importation.Importing) { + current.copy(isCancelling = true) + } else { + current + } + } + + // Attempt to cancel + when (modelRepository.cancelImport()) { + null, true -> { _managementState.value = ModelManagementState.Idle } + false -> { + _managementState.value = Importation.Error( + message = "Failed to cancel import. Try again later." + ) + } + } + } + } + + /** + * Query models on HuggingFace available for download even without signing in + */ + fun queryModelsFromHuggingFace() { + huggingFaceQueryJob = viewModelScope.launch { + _managementState.emit(Download.Querying) + try { + modelRepository.searchHuggingFaceModels().fold( + onSuccess = { models -> + Log.d(TAG, "Fetched ${models.size} models from HuggingFace:") + _managementState.emit(Download.Ready(models)) + }, + onFailure = { throw it } + ) + } catch (_: CancellationException) { + // no-op + } catch (_: UnknownHostException) { + _managementState.value = Download.Error(message = "No internet connection") + } catch (_: SocketTimeoutException) { + _managementState.value = Download.Error(message = "Connection timed out") + } catch (_: FileNotFoundException) { + _managementState.emit(Download.Error(message = "No eligible models")) + } catch (e: IOException) { + _managementState.value = Download.Error(message = "Network error: ${e.message}") + } catch (e: Exception) { + _managementState.emit(Download.Error(message = e.message ?: "Unknown error")) + } + } + } + + /** + * Dispatch download request to [DownloadManager] and update UI + */ + fun downloadHuggingFaceModelConfirmed(model: HuggingFaceModel) = viewModelScope.launch { + try { + require(!model.gated) { "Model is gated!" } + require(!model.private) { "Model is private!" } + val downloadInfo = model.toDownloadInfo() + requireNotNull(downloadInfo) { "Download URL is missing!" } + + modelRepository.getHuggingFaceModelFileSize(downloadInfo).fold( + onSuccess = { actualSize -> + Log.d(TAG, "Model file size: ${formatFileByteSize(actualSize)}") + modelRepository.downloadHuggingFaceModel(downloadInfo, actualSize) + .onSuccess { downloadId -> + activeDownloads[downloadId] = model + _managementState.value = Download.Dispatched(downloadInfo) + } + .onFailure { throw it } + }, + onFailure = { throw it } + ) + } catch (_: UnknownHostException) { + _managementState.value = Download.Error(message = "No internet connection") + } catch (_: SocketTimeoutException) { + _managementState.value = Download.Error(message = "Connection timed out") + } catch (e: IOException) { + _managementState.value = Download.Error(message = "Network error: ${e.message}") + } catch (e: InsufficientStorageException) { + _managementState.value = Download.Error( + message = e.message ?: "Insufficient storage space to download ${model.modelId}", + ) + } catch (e: Exception) { + _managementState.value = Download.Error( + message = e.message ?: "Unknown error downloading ${model.modelId}", + ) + } + } + + private fun handleDownloadComplete(downloadId: Long) = viewModelScope.launch { + val model = activeDownloads.remove(downloadId) ?: return@launch + + (context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager) + .getUriForDownloadedFile(downloadId)?.let { uri -> + try { + val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A") + val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A") + _managementState.emit(Download.Completed(model, uri, fileName, fileSize)) + } catch (e: Exception) { + _managementState.value = Download.Error( + message = e.message ?: "Unknown error downloading ${model.modelId}" + ) + } + } + } + + /** + * First show confirmation instead of starting deletion immediately + */ + fun batchDeletionClicked(models: Map) { + _managementState.value = Deletion.Confirming(models) + } + + /** + * Delete multiple models one by one while updating UI states with realtime progress + */ + fun deleteModels(modelsToDelete: Map) = viewModelScope.launch { + val total = modelsToDelete.size + if (total == 0) return@launch + + try { + _managementState.value = Deletion.Deleting(0f, modelsToDelete) + var deleted = 0 + modelsToDelete.keys.toList().forEach { + modelRepository.deleteModel(it) + deleted++ + _managementState.value = Deletion.Deleting(deleted.toFloat() / total, modelsToDelete) + } + _managementState.value = Deletion.Success(modelsToDelete.values.toList()) + clearAllSelectedModelsToDelete() + + // Reset state after a delay + delay(DELETE_SUCCESS_RESET_TIMEOUT_MS) + _managementState.value = ModelManagementState.Idle + } catch (e: Exception) { + _managementState.value = Deletion.Error( + message = e.message ?: "Error deleting $total models" + ) + } + } + + companion object { + private val TAG = ModelsManagementViewModel::class.java.simpleName + + private const val DELETE_SUCCESS_RESET_TIMEOUT_MS = 1000L + } +} + + +sealed class ModelManagementState { + object Idle : ModelManagementState() + + sealed class Importation : ModelManagementState() { + data class Confirming(val uri: Uri, val fileName: String, val fileSize: Long) : Importation() + data class Importing(val progress: Float = 0f, val fileName: String, val fileSize: Long, val isCancelling: Boolean = false) : Importation() + data class Success(val model: ModelInfo) : Importation() + data class Error(val message: String, val learnMoreUrl: String? = null) : Importation() + } + + sealed class Download: ModelManagementState() { + object Querying : Download() + data class Ready(val models: List) : Download() + data class Dispatched(val downloadInfo: HuggingFaceDownloadInfo) : Download() + data class Completed(val model: HuggingFaceModel, val uri: Uri, val fileName: String, val fileSize: Long) : Download() + data class Error(val message: String) : Download() + } + + sealed class Deletion : ModelManagementState() { + data class Confirming(val models: Map): ModelManagementState() + data class Deleting(val progress: Float = 0f, val models: Map) : ModelManagementState() + data class Success(val models: List) : Deletion() + data class Error(val message: String) : Deletion() + } +} diff --git a/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsViewModel.kt b/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsViewModel.kt index b26f77c9b0..4f3c7e0729 100644 --- a/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsViewModel.kt +++ b/examples/llama.android/app/src/main/java/com/example/llama/viewmodel/ModelsViewModel.kt @@ -1,14 +1,5 @@ package com.example.llama.viewmodel -import android.app.DownloadManager -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Context.RECEIVER_EXPORTED -import android.content.Intent -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 @@ -20,24 +11,12 @@ 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.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.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -48,18 +27,12 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.io.FileNotFoundException -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 ModelsViewModel @Inject constructor( - @ApplicationContext private val context: Context, private val modelRepository: ModelRepository, private val performanceMonitor: PerformanceMonitor, private val inferenceService: InferenceService, @@ -185,65 +158,6 @@ class ModelsViewModel @Inject constructor( initialValue = null ) - // UI state: models selected in deleting mode - private val _selectedModelsToDelete = MutableStateFlow>(emptyMap()) - val selectedModelsToDelete: StateFlow> = _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 = _showImportModelMenu.asStateFlow() - - fun toggleImportMenu(show: Boolean) { - _showImportModelMenu.value = show - } - - // HuggingFace: ongoing query jobs - private var huggingFaceQueryJob: Job? = null - - // HuggingFace: Ongoing download jobs - private val activeDownloads = mutableMapOf() - private val downloadReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1).let { id -> - if (id in activeDownloads) { - handleDownloadComplete(id) - } - } - } - } - - // Internal state - private val _managementState = MutableStateFlow(ModelManagementState.Idle) - val managementState: StateFlow = _managementState.asStateFlow() - - fun resetManagementState() { - huggingFaceQueryJob?.let { - if (it.isActive) { it.cancel() } - } - _managementState.value = ModelManagementState.Idle - } init { viewModelScope.launch { @@ -272,9 +186,6 @@ class ModelsViewModel @Inject constructor( _queryResults.value = it } } - - val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) - context.registerReceiver(downloadReceiver, filter, RECEIVER_EXPORTED) } /** @@ -335,193 +246,6 @@ class ModelsViewModel @Inject constructor( false } - /** - * First show confirmation instead of starting import local file immediately - */ - fun importLocalModelFileSelected(uri: Uri) = viewModelScope.launch { - try { - val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A") - val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A") - _managementState.value = Importation.Confirming(uri, fileName, fileSize) - } catch (e: Exception) { - _managementState.value = Importation.Error( - message = e.message ?: "Unknown error preparing import" - ) - } - } - - - /** - * Import a local model file from device storage while updating UI states with realtime progress - */ - fun importLocalModelFileConfirmed(uri: Uri, fileName: String, fileSize: Long) = viewModelScope.launch { - try { - _managementState.value = Importation.Importing(0f, fileName, fileSize) - val model = modelRepository.importModel(uri, fileName, fileSize) { progress -> - _managementState.value = Importation.Importing(progress, fileName, fileSize) - } - _managementState.value = Importation.Success(model) - } catch (_: InvalidFileFormatException) { - _managementState.value = Importation.Error( - message = "Not a valid GGUF model!", - learnMoreUrl = "https://huggingface.co/docs/hub/en/gguf", - ) - } catch (e: InsufficientStorageException) { - _managementState.value = Importation.Error( - message = e.message ?: "Insufficient storage space to import $fileName", - learnMoreUrl = "https://support.google.com/android/answer/7431795?hl=en", - ) - } catch (e: Exception) { - Log.e(TAG, "Unknown exception importing $fileName", e) - _managementState.value = Importation.Error( - message = e.message ?: "Unknown error importing $fileName", - ) - } - } - - fun cancelOngoingLocalModelImport() = viewModelScope.launch { - viewModelScope.launch { - // First update UI to show we're attempting to cancel - _managementState.update { current -> - if (current is Importation.Importing) { - current.copy(isCancelling = true) - } else { - current - } - } - - // Attempt to cancel - when (modelRepository.cancelImport()) { - null, true -> { _managementState.value = ModelManagementState.Idle } - false -> { - _managementState.value = Importation.Error( - message = "Failed to cancel import. Try again later." - ) - } - } - } - } - - /** - * Query models on HuggingFace available for download even without signing in - */ - fun queryModelsFromHuggingFace() { - huggingFaceQueryJob = viewModelScope.launch { - _managementState.emit(Download.Querying) - try { - modelRepository.searchHuggingFaceModels().fold( - onSuccess = { models -> - Log.d(TAG, "Fetched ${models.size} models from HuggingFace:") - _managementState.emit(Download.Ready(models)) - }, - onFailure = { throw it } - ) - } catch (_: CancellationException) { - // no-op - } catch (_: UnknownHostException) { - _managementState.value = Download.Error(message = "No internet connection") - } catch (_: SocketTimeoutException) { - _managementState.value = Download.Error(message = "Connection timed out") - } catch (_: FileNotFoundException) { - _managementState.emit(Download.Error(message = "No eligible models")) - } catch (e: IOException) { - _managementState.value = Download.Error(message = "Network error: ${e.message}") - } catch (e: Exception) { - _managementState.emit(Download.Error(message = e.message ?: "Unknown error")) - } - } - } - - /** - * Dispatch download request to [DownloadManager] and update UI - */ - fun downloadHuggingFaceModelConfirmed(model: HuggingFaceModel) = viewModelScope.launch { - try { - require(!model.gated) { "Model is gated!" } - require(!model.private) { "Model is private!" } - val downloadInfo = model.toDownloadInfo() - requireNotNull(downloadInfo) { "Download URL is missing!" } - - modelRepository.getHuggingFaceModelFileSize(downloadInfo).fold( - onSuccess = { actualSize -> - Log.d(TAG, "Model file size: ${formatFileByteSize(actualSize)}") - modelRepository.downloadHuggingFaceModel(downloadInfo, actualSize) - .onSuccess { downloadId -> - activeDownloads[downloadId] = model - _managementState.value = Download.Dispatched(downloadInfo) - } - .onFailure { throw it } - }, - onFailure = { throw it } - ) - } catch (_: UnknownHostException) { - _managementState.value = Download.Error(message = "No internet connection") - } catch (_: SocketTimeoutException) { - _managementState.value = Download.Error(message = "Connection timed out") - } catch (e: IOException) { - _managementState.value = Download.Error(message = "Network error: ${e.message}") - } catch (e: InsufficientStorageException) { - _managementState.value = Download.Error( - message = e.message ?: "Insufficient storage space to download ${model.modelId}", - ) - } catch (e: Exception) { - _managementState.value = Download.Error( - message = e.message ?: "Unknown error downloading ${model.modelId}", - ) - } - } - - private fun handleDownloadComplete(downloadId: Long) = viewModelScope.launch { - val model = activeDownloads.remove(downloadId) ?: return@launch - - (context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager) - .getUriForDownloadedFile(downloadId)?.let { uri -> - try { - val fileName = getFileNameFromUri(context, uri) ?: throw FileNotFoundException("File size N/A") - val fileSize = getFileSizeFromUri(context, uri) ?: throw FileNotFoundException("File name N/A") - _managementState.emit(Download.Completed(model, uri, fileName, fileSize)) - } catch (e: Exception) { - _managementState.value = Download.Error( - message = e.message ?: "Unknown error downloading ${model.modelId}" - ) - } - } - } - - /** - * First show confirmation instead of starting deletion immediately - */ - fun batchDeletionClicked(models: Map) { - _managementState.value = Deletion.Confirming(models) - } - - /** - * Delete multiple models one by one while updating UI states with realtime progress - */ - fun deleteModels(models: Map) = viewModelScope.launch { - val total = models.size - if (total == 0) return@launch - - try { - _managementState.value = Deletion.Deleting(0f, models) - var deleted = 0 - models.keys.toList().forEach { - modelRepository.deleteModel(it) - deleted++ - _managementState.value = Deletion.Deleting(deleted.toFloat() / total, models) - } - _managementState.value = Deletion.Success(models.values.toList()) - toggleAllSelectedModelsToDelete(false) - - // Reset state after a delay - delay(DELETE_SUCCESS_RESET_TIMEOUT_MS) - _managementState.value = ModelManagementState.Idle - } catch (e: Exception) { - _managementState.value = Deletion.Error( - message = e.message ?: "Error deleting $total models" - ) - } - } companion object { private val TAG = ModelsViewModel::class.java.simpleName @@ -529,8 +253,6 @@ class ModelsViewModel @Inject constructor( 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 } } @@ -552,29 +274,3 @@ data class PreselectedModelToRun( val showing: Boolean, ) } - -sealed class ModelManagementState { - object Idle : ModelManagementState() - - sealed class Importation : ModelManagementState() { - data class Confirming(val uri: Uri, val fileName: String, val fileSize: Long) : Importation() - data class Importing(val progress: Float = 0f, val fileName: String, val fileSize: Long, val isCancelling: Boolean = false) : Importation() - data class Success(val model: ModelInfo) : Importation() - data class Error(val message: String, val learnMoreUrl: String? = null) : Importation() - } - - sealed class Download: ModelManagementState() { - object Querying : Download() - data class Ready(val models: List) : Download() - data class Dispatched(val downloadInfo: HuggingFaceDownloadInfo) : Download() - data class Completed(val model: HuggingFaceModel, val uri: Uri, val fileName: String, val fileSize: Long) : Download() - data class Error(val message: String) : Download() - } - - sealed class Deletion : ModelManagementState() { - data class Confirming(val models: Map): ModelManagementState() - data class Deleting(val progress: Float = 0f, val models: Map) : ModelManagementState() - data class Success(val models: List) : Deletion() - data class Error(val message: String) : Deletion() - } -}