UI: split the ModelsManagementViewModel from a unified ModelsViewModel due to huge complexity

This commit is contained in:
Han Yin 2025-08-30 17:58:05 -07:00
parent df16abe75e
commit a4881cb87b
11 changed files with 442 additions and 386 deletions

View File

@ -53,9 +53,10 @@ import com.example.llama.viewmodel.BenchmarkViewModel
import com.example.llama.viewmodel.ConversationViewModel import com.example.llama.viewmodel.ConversationViewModel
import com.example.llama.viewmodel.MainViewModel import com.example.llama.viewmodel.MainViewModel
import com.example.llama.viewmodel.ModelLoadingViewModel import com.example.llama.viewmodel.ModelLoadingViewModel
import com.example.llama.viewmodel.ModelScreenUiMode
import com.example.llama.viewmodel.ModelsManagementViewModel
import com.example.llama.viewmodel.ModelsViewModel import com.example.llama.viewmodel.ModelsViewModel
import com.example.llama.viewmodel.SettingsViewModel import com.example.llama.viewmodel.SettingsViewModel
import com.example.llama.viewmodel.ModelScreenUiMode
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -85,10 +86,10 @@ fun AppContent(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
mainViewModel: MainViewModel = hiltViewModel(), mainViewModel: MainViewModel = hiltViewModel(),
modelsViewModel: ModelsViewModel = hiltViewModel(), modelsViewModel: ModelsViewModel = hiltViewModel(),
modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
modelLoadingViewModel: ModelLoadingViewModel = hiltViewModel(), modelLoadingViewModel: ModelLoadingViewModel = hiltViewModel(),
benchmarkViewModel: BenchmarkViewModel = hiltViewModel(), benchmarkViewModel: BenchmarkViewModel = hiltViewModel(),
conversationViewModel: ConversationViewModel = hiltViewModel(), conversationViewModel: ConversationViewModel = hiltViewModel(),
// modelsManagementViewModel: ModelsManagementViewModel = hiltViewModel(),
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@ -191,19 +192,20 @@ fun AppContent(
// Model selection screen // Model selection screen
currentRoute == AppDestinations.MODELS_ROUTE -> { currentRoute == AppDestinations.MODELS_ROUTE -> {
// Collect states for bottom bar // Collect states for bottom bar
val filteredModels by modelsViewModel.filteredModels.collectAsState()
val sortOrder by modelsViewModel.sortOrder.collectAsState() val sortOrder by modelsViewModel.sortOrder.collectAsState()
val showSortMenu by modelsViewModel.showSortMenu.collectAsState() val showSortMenu by modelsViewModel.showSortMenu.collectAsState()
val activeFilters by modelsViewModel.activeFilters.collectAsState() val activeFilters by modelsViewModel.activeFilters.collectAsState()
val showFilterMenu by modelsViewModel.showFilterMenu.collectAsState() val showFilterMenu by modelsViewModel.showFilterMenu.collectAsState()
val preselection by modelsViewModel.preselectedModelToRun.collectAsState() val preselection by modelsViewModel.preselectedModelToRun.collectAsState()
val selectedModelsToDelete by modelsViewModel.selectedModelsToDelete.collectAsState() val selectedModelsToDelete by modelsManagementViewModel.selectedModelsToDelete.collectAsState()
val showImportModelMenu by modelsViewModel.showImportModelMenu.collectAsState() val showImportModelMenu by modelsManagementViewModel.showImportModelMenu.collectAsState()
// Create file launcher for importing local models // Create file launcher for importing local models
val fileLauncher = rememberLauncherForActivityResult( val fileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument() contract = ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { modelsViewModel.importLocalModelFileSelected(it) } } ) { uri -> uri?.let { modelsManagementViewModel.importLocalModelFileSelected(it) } }
ScaffoldConfig( ScaffoldConfig(
topBarConfig = topBarConfig =
@ -231,6 +233,7 @@ fun AppContent(
TopBarConfig.ModelsDeleting( TopBarConfig.ModelsDeleting(
title = "Deleting models", title = "Deleting models",
navigationIcon = NavigationIcon.Quit { navigationIcon = NavigationIcon.Quit {
modelsManagementViewModel.resetManagementState()
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING) modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
}, },
) )
@ -309,14 +312,14 @@ fun AppContent(
), ),
importing = BottomBarConfig.Models.Management.ImportConfig( importing = BottomBarConfig.Models.Management.ImportConfig(
isMenuVisible = showImportModelMenu, isMenuVisible = showImportModelMenu,
toggleMenu = { show -> modelsViewModel.toggleImportMenu(show) }, toggleMenu = { show -> modelsManagementViewModel.toggleImportMenu(show) },
importFromLocal = { importFromLocal = {
fileLauncher.launch(arrayOf("application/octet-stream", "*/*")) fileLauncher.launch(arrayOf("application/octet-stream", "*/*"))
modelsViewModel.toggleImportMenu(false) modelsManagementViewModel.toggleImportMenu(false)
}, },
importFromHuggingFace = { importFromHuggingFace = {
modelsViewModel.queryModelsFromHuggingFace() modelsManagementViewModel.queryModelsFromHuggingFace()
modelsViewModel.toggleImportMenu(false) modelsManagementViewModel.toggleImportMenu(false)
} }
), ),
onToggleDeleting = { onToggleDeleting = {
@ -330,11 +333,16 @@ fun AppContent(
modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING) modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
}, },
selectedModels = selectedModelsToDelete, selectedModels = selectedModelsToDelete,
toggleAllSelection = { modelsViewModel.toggleAllSelectedModelsToDelete(it) }, selectAllFilteredModels = {
modelsManagementViewModel.selectAllFilteredModelsToDelete(filteredModels)
},
clearAllSelectedModels = {
modelsManagementViewModel.clearAllSelectedModelsToDelete()
},
deleteSelected = { deleteSelected = {
selectedModelsToDelete.let { selectedModelsToDelete.let {
if (it.isNotEmpty()) { if (it.isNotEmpty()) {
modelsViewModel.batchDeletionClicked(it) modelsManagementViewModel.batchDeletionClicked(it)
} }
} }
}, },
@ -464,7 +472,8 @@ fun AppContent(
} }
}, },
onScaffoldEvent = handleScaffoldEvent, onScaffoldEvent = handleScaffoldEvent,
viewModel = modelsViewModel modelsViewModel = modelsViewModel,
managementViewModel = modelsManagementViewModel,
) )
} }

View File

@ -1,6 +1,7 @@
package com.example.llama.ui.scaffold package com.example.llama.ui.scaffold
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@ -77,6 +78,8 @@ fun AppScaffold(
is TopBarConfig.ModelsDeleting -> DefaultTopBar( is TopBarConfig.ModelsDeleting -> DefaultTopBar(
title = topBarconfig.title, title = topBarconfig.title,
titleColor = MaterialTheme.colorScheme.error,
navigationIconTint = MaterialTheme.colorScheme.error,
onQuit = topBarconfig.navigationIcon.quitAction onQuit = topBarconfig.navigationIcon.quitAction
) )

View File

@ -78,7 +78,8 @@ sealed class BottomBarConfig {
data class Deleting( data class Deleting(
val onQuitDeleting: () -> Unit, val onQuitDeleting: () -> Unit,
val selectedModels: Map<String, ModelInfo>, val selectedModels: Map<String, ModelInfo>,
val toggleAllSelection: (Boolean) -> Unit, val selectAllFilteredModels: () -> Unit,
val clearAllSelectedModels: () -> Unit,
val deleteSelected: () -> Unit val deleteSelected: () -> Unit
) : BottomBarConfig() ) : BottomBarConfig()

View File

@ -23,14 +23,14 @@ fun ModelsDeletingBottomBar(
) { ) {
BottomAppBar( BottomAppBar(
actions = { actions = {
IconButton(onClick = { deleting.toggleAllSelection(false) }) { IconButton(onClick = { deleting.clearAllSelectedModels() }) {
Icon( Icon(
imageVector = Icons.Default.ClearAll, imageVector = Icons.Default.ClearAll,
contentDescription = "Deselect all" contentDescription = "Deselect all"
) )
} }
IconButton(onClick = { deleting.toggleAllSelection(true) }) { IconButton(onClick = { deleting.selectAllFilteredModels() }) {
Icon( Icon(
imageVector = Icons.Default.SelectAll, imageVector = Icons.Default.SelectAll,
contentDescription = "Select all" contentDescription = "Select all"

View File

@ -12,24 +12,30 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DefaultTopBar( fun DefaultTopBar(
title: String, title: String,
titleColor: Color = Color.Unspecified,
navigationIconTint: Color = Color.Unspecified,
onNavigateBack: (() -> Unit)? = null, onNavigateBack: (() -> Unit)? = null,
onQuit: (() -> Unit)? = null, onQuit: (() -> Unit)? = null,
onMenuOpen: (() -> Unit)? = null onMenuOpen: (() -> Unit)? = null
) { ) {
TopAppBar( TopAppBar(
title = { Text(title) }, title = {
Text(text = title, color = titleColor)
},
navigationIcon = { navigationIcon = {
when { when {
onQuit != null -> { onQuit != null -> {
IconButton(onClick = onQuit) { IconButton(onClick = onQuit) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Quit" contentDescription = "Quit",
tint = navigationIconTint
) )
} }
} }
@ -38,7 +44,8 @@ fun DefaultTopBar(
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back" contentDescription = "Back",
tint = navigationIconTint
) )
} }
} }
@ -47,7 +54,8 @@ fun DefaultTopBar(
IconButton(onClick = onMenuOpen) { IconButton(onClick = onMenuOpen) {
Icon( Icon(
imageVector = Icons.Default.Menu, imageVector = Icons.Default.Menu,
contentDescription = "Menu" contentDescription = "Menu",
tint = navigationIconTint
) )
} }
} }

View File

@ -9,33 +9,23 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.unit.dp 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.InfoAction
import com.example.llama.ui.components.InfoView import com.example.llama.ui.components.InfoView
import com.example.llama.ui.components.ModelCardFullExpandable import com.example.llama.ui.components.ModelCardFullExpandable
import com.example.llama.viewmodel.ModelsViewModel import com.example.llama.viewmodel.ModelsViewModel
import com.example.llama.viewmodel.PreselectedModelToRun
@Composable @Composable
fun ModelsBrowsingScreen( fun ModelsBrowsingScreen(
filteredModels: List<ModelInfo>,
activeFiltersCount: Int,
preselection: PreselectedModelToRun?,
onManageModelsClicked: () -> Unit, onManageModelsClicked: () -> Unit,
viewModel: ModelsViewModel, 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()) { if (filteredModels.isEmpty()) {
// Empty model prompt // Empty model prompt
EmptyModelsView(activeFiltersCount, onManageModelsClicked) EmptyModelsView(activeFiltersCount, onManageModelsClicked)
@ -63,7 +53,6 @@ fun ModelsBrowsingScreen(
} }
} }
@Composable @Composable
private fun EmptyModelsView( private fun EmptyModelsView(
activeFiltersCount: Int, activeFiltersCount: Int,

View File

@ -39,7 +39,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf 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.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri import androidx.core.net.toUri
import com.example.llama.data.model.ModelFilter
import com.example.llama.data.model.ModelInfo import com.example.llama.data.model.ModelInfo
import com.example.llama.data.source.remote.HuggingFaceModel import com.example.llama.data.source.remote.HuggingFaceModel
import com.example.llama.ui.components.InfoAction 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.Download
import com.example.llama.viewmodel.ModelManagementState.Importation import com.example.llama.viewmodel.ModelManagementState.Importation
import com.example.llama.viewmodel.ModelScreenUiMode import com.example.llama.viewmodel.ModelScreenUiMode
import com.example.llama.viewmodel.ModelsManagementViewModel
import com.example.llama.viewmodel.ModelsViewModel import com.example.llama.viewmodel.ModelsViewModel
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@ -77,24 +78,18 @@ import java.util.Locale
*/ */
@Composable @Composable
fun ModelsManagementAndDeletingScreen( fun ModelsManagementAndDeletingScreen(
filteredModels: List<ModelInfo>,
activeFiltersCount: Int,
isDeleting: Boolean, isDeleting: Boolean,
onScaffoldEvent: (ScaffoldEvent) -> Unit, onScaffoldEvent: (ScaffoldEvent) -> Unit,
viewModel: ModelsViewModel, modelsViewModel: ModelsViewModel,
managementViewModel: ModelsManagementViewModel,
) { ) {
// Data: models
val filteredModels by viewModel.filteredModels.collectAsState()
// Selection state // Selection state
val selectedModels by viewModel.selectedModelsToDelete.collectAsState() val selectedModels by managementViewModel.selectedModelsToDelete.collectAsState()
// Filter state
val activeFilters by viewModel.activeFilters.collectAsState()
val activeFiltersCount by remember(activeFilters) {
derivedStateOf { activeFilters.count { it.value } }
}
// Model management state // Model management state
val managementState by viewModel.managementState.collectAsState() val managementState by managementViewModel.managementState.collectAsState()
// UI states // UI states
val expandedModels = remember { mutableStateMapOf<String, ModelInfo>() } val expandedModels = remember { mutableStateMapOf<String, ModelInfo>() }
@ -136,7 +131,7 @@ fun ModelsManagementAndDeletingScreen(
isSelected = isSelected, isSelected = isSelected,
onSelected = { onSelected = {
if (isDeleting) { if (isDeleting) {
viewModel.toggleModelSelectionById(model.id) managementViewModel.toggleModelSelectionById(filteredModels, model.id)
} }
}, },
isExpanded = expandedModels.contains(model.id), isExpanded = expandedModels.contains(model.id),
@ -161,11 +156,11 @@ fun ModelsManagementAndDeletingScreen(
isImporting = false, isImporting = false,
progress = 0.0f, progress = 0.0f,
onConfirm = { onConfirm = {
viewModel.importLocalModelFileConfirmed( managementViewModel.importLocalModelFileConfirmed(
state.uri, state.fileName, state.fileSize state.uri, state.fileName, state.fileSize
) )
}, },
onCancel = { viewModel.resetManagementState() } onCancel = { managementViewModel.resetManagementState() }
) )
} }
@ -177,7 +172,7 @@ fun ModelsManagementAndDeletingScreen(
isCancelling = state.isCancelling, isCancelling = state.isCancelling,
progress = state.progress, progress = state.progress,
onConfirm = {}, onConfirm = {},
onCancel = { viewModel.cancelOngoingLocalModelImport() }, onCancel = { managementViewModel.cancelOngoingLocalModelImport() },
) )
} }
@ -186,7 +181,7 @@ fun ModelsManagementAndDeletingScreen(
title = "Import Failed", title = "Import Failed",
message = state.message, message = state.message,
learnMoreUrl = state.learnMoreUrl, learnMoreUrl = state.learnMoreUrl,
onDismiss = { viewModel.resetManagementState() } onDismiss = { managementViewModel.resetManagementState() }
) )
} }
@ -198,21 +193,21 @@ fun ModelsManagementAndDeletingScreen(
) )
) )
viewModel.resetManagementState() managementViewModel.resetManagementState()
} }
} }
is Download.Querying -> { is Download.Querying -> {
ImportFromHuggingFaceDialog( ImportFromHuggingFaceDialog(
onCancel = { viewModel.resetManagementState() } onCancel = { managementViewModel.resetManagementState() }
) )
} }
is Download.Ready -> { is Download.Ready -> {
ImportFromHuggingFaceDialog( ImportFromHuggingFaceDialog(
models = state.models, models = state.models,
onConfirm = { viewModel.downloadHuggingFaceModelConfirmed(it) }, onConfirm = { managementViewModel.downloadHuggingFaceModelConfirmed(it) },
onCancel = { viewModel.resetManagementState() } onCancel = { managementViewModel.resetManagementState() }
) )
} }
@ -225,7 +220,7 @@ fun ModelsManagementAndDeletingScreen(
) )
) )
viewModel.resetManagementState() managementViewModel.resetManagementState()
} }
} }
@ -236,11 +231,11 @@ fun ModelsManagementAndDeletingScreen(
isImporting = false, isImporting = false,
progress = 0.0f, progress = 0.0f,
onConfirm = { onConfirm = {
viewModel.importLocalModelFileConfirmed( managementViewModel.importLocalModelFileConfirmed(
state.uri, state.fileName, state.fileSize state.uri, state.fileName, state.fileSize
) )
}, },
onCancel = { viewModel.resetManagementState() } onCancel = { managementViewModel.resetManagementState() }
) )
} }
@ -248,15 +243,15 @@ fun ModelsManagementAndDeletingScreen(
ErrorDialog( ErrorDialog(
title = "Download Failed", title = "Download Failed",
message = state.message, message = state.message,
onDismiss = { viewModel.resetManagementState() } onDismiss = { managementViewModel.resetManagementState() }
) )
} }
is Deletion.Confirming -> { is Deletion.Confirming -> {
BatchDeleteConfirmationDialog( BatchDeleteConfirmationDialog(
count = state.models.size, count = state.models.size,
onConfirm = { viewModel.deleteModels(state.models) }, onConfirm = { managementViewModel.deleteModels(state.models) },
onDismiss = { viewModel.resetManagementState() }, onDismiss = { managementViewModel.resetManagementState() },
isDeleting = false isDeleting = false
) )
} }
@ -274,19 +269,20 @@ fun ModelsManagementAndDeletingScreen(
ErrorDialog( ErrorDialog(
title = "Deletion Failed", title = "Deletion Failed",
message = state.message, message = state.message,
onDismiss = { viewModel.resetManagementState() } onDismiss = { managementViewModel.resetManagementState() }
) )
} }
is Deletion.Success -> { is Deletion.Success -> {
LaunchedEffect(state) { LaunchedEffect(state) {
viewModel.toggleMode(ModelScreenUiMode.MANAGING) modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
val count = state.models.size val count = state.models.size
onScaffoldEvent( onScaffoldEvent(
ScaffoldEvent.ShowSnackbar( ScaffoldEvent.ShowSnackbar(
message = "Deleted $count ${if (count > 1) "models" else "model"}.", message = "Deleted $count ${if (count > 1) "models" else "model"}.",
duration = SnackbarDuration.Long withDismissAction = true,
duration = SnackbarDuration.Long,
) )
) )
} }

View File

@ -13,13 +13,16 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.example.llama.data.model.ModelInfo import com.example.llama.data.model.ModelInfo
import com.example.llama.ui.components.InfoView import com.example.llama.ui.components.InfoView
import com.example.llama.ui.scaffold.ScaffoldEvent import com.example.llama.ui.scaffold.ScaffoldEvent
import com.example.llama.util.formatFileByteSize import com.example.llama.util.formatFileByteSize
import com.example.llama.viewmodel.ModelScreenUiMode import com.example.llama.viewmodel.ModelScreenUiMode
import com.example.llama.viewmodel.ModelsManagementViewModel
import com.example.llama.viewmodel.ModelsViewModel import com.example.llama.viewmodel.ModelsViewModel
import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning import com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
@ -29,31 +32,39 @@ fun ModelsScreen(
onManageModelsClicked: () -> Unit, onManageModelsClicked: () -> Unit,
onConfirmSelection: (ModelInfo, RamWarning) -> Unit, onConfirmSelection: (ModelInfo, RamWarning) -> Unit,
onScaffoldEvent: (ScaffoldEvent) -> Unit, onScaffoldEvent: (ScaffoldEvent) -> Unit,
viewModel: ModelsViewModel, modelsViewModel: ModelsViewModel,
managementViewModel: ModelsManagementViewModel,
) { ) {
// Data // 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 // UI states
val currentMode by viewModel.modelScreenUiMode.collectAsState() val currentMode by modelsViewModel.modelScreenUiMode.collectAsState()
// Handle back button press // Handle back button press
BackHandler { BackHandler {
when (currentMode) { when (currentMode) {
ModelScreenUiMode.BROWSING -> { ModelScreenUiMode.BROWSING -> {
if (preselection != null) { if (preselection != null) {
viewModel.resetPreselection() modelsViewModel.resetPreselection()
} }
} }
ModelScreenUiMode.SEARCHING -> { ModelScreenUiMode.SEARCHING -> {
viewModel.toggleMode(ModelScreenUiMode.BROWSING) modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
} }
ModelScreenUiMode.MANAGING -> { ModelScreenUiMode.MANAGING -> {
viewModel.toggleMode(ModelScreenUiMode.BROWSING) modelsViewModel.toggleMode(ModelScreenUiMode.BROWSING)
} }
ModelScreenUiMode.DELETING -> { ModelScreenUiMode.DELETING -> {
viewModel.toggleAllSelectedModelsToDelete(false) managementViewModel.clearAllSelectedModelsToDelete()
viewModel.toggleMode(ModelScreenUiMode.MANAGING) modelsViewModel.toggleMode(ModelScreenUiMode.MANAGING)
} }
} }
} }
@ -64,16 +75,25 @@ fun ModelsScreen(
when (currentMode) { when (currentMode) {
ModelScreenUiMode.BROWSING -> ModelScreenUiMode.BROWSING ->
ModelsBrowsingScreen( ModelsBrowsingScreen(
filteredModels = filteredModels,
preselection = preselection,
onManageModelsClicked = { /* TODO-han.yin */ }, onManageModelsClicked = { /* TODO-han.yin */ },
viewModel = viewModel activeFiltersCount = activeFiltersCount,
viewModel = modelsViewModel,
) )
ModelScreenUiMode.SEARCHING -> ModelScreenUiMode.SEARCHING ->
ModelsSearchingScreen(viewModel = viewModel) ModelsSearchingScreen(
preselection = preselection,
viewModel = modelsViewModel
)
ModelScreenUiMode.MANAGING, ModelScreenUiMode.DELETING -> ModelScreenUiMode.MANAGING, ModelScreenUiMode.DELETING ->
ModelsManagementAndDeletingScreen( ModelsManagementAndDeletingScreen(
filteredModels = filteredModels,
isDeleting = currentMode == ModelScreenUiMode.DELETING, isDeleting = currentMode == ModelScreenUiMode.DELETING,
onScaffoldEvent = onScaffoldEvent, onScaffoldEvent = onScaffoldEvent,
viewModel = viewModel activeFiltersCount = activeFiltersCount,
modelsViewModel = modelsViewModel,
managementViewModel = managementViewModel,
) )
} }
@ -83,7 +103,7 @@ fun ModelsScreen(
if (warning.showing) { if (warning.showing) {
RamErrorDialog( RamErrorDialog(
warning, warning,
onDismiss = { viewModel.dismissRamWarning() }, onDismiss = { modelsViewModel.dismissRamWarning() },
onConfirm = { onConfirmSelection(it.modelInfo, warning) } onConfirm = { onConfirmSelection(it.modelInfo, warning) }
) )
} }

View File

@ -39,15 +39,14 @@ import androidx.compose.ui.unit.dp
import com.example.llama.ui.components.ModelCardFullExpandable import com.example.llama.ui.components.ModelCardFullExpandable
import com.example.llama.viewmodel.ModelScreenUiMode import com.example.llama.viewmodel.ModelScreenUiMode
import com.example.llama.viewmodel.ModelsViewModel import com.example.llama.viewmodel.ModelsViewModel
import com.example.llama.viewmodel.PreselectedModelToRun
@ExperimentalMaterial3Api @ExperimentalMaterial3Api
@Composable @Composable
fun ModelsSearchingScreen( fun ModelsSearchingScreen(
preselection: PreselectedModelToRun?,
viewModel: ModelsViewModel, viewModel: ModelsViewModel,
) { ) {
val preselection by viewModel.preselectedModelToRun.collectAsState()
// Query states // Query states
val textFieldState = viewModel.searchFieldState val textFieldState = viewModel.searchFieldState
val searchQuery by remember(textFieldState) { val searchQuery by remember(textFieldState) {
@ -68,7 +67,7 @@ fun ModelsSearchingScreen(
} }
} }
// TODO-han.yin: remove after validation
// LaunchedEffect (isSearchActive) { // LaunchedEffect (isSearchActive) {
// if (isSearchActive) { // if (isSearchActive) {
// toggleSearchFocusAndIme(true) // toggleSearchFocusAndIme(true)

View File

@ -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<Map<String, ModelInfo>>(emptyMap())
val selectedModelsToDelete: StateFlow<Map<String, ModelInfo>> = _selectedModelsToDelete.asStateFlow()
fun toggleModelSelectionById(filteredModels: List<ModelInfo>, 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<ModelInfo>) {
_selectedModelsToDelete.value = filteredModels.associateBy { it.id }
}
fun clearAllSelectedModelsToDelete() {
_selectedModelsToDelete.value = emptyMap()
}
// UI state: import menu
private val _showImportModelMenu = MutableStateFlow(false)
val showImportModelMenu: StateFlow<Boolean> = _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<Long, HuggingFaceModel>()
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>(ModelManagementState.Idle)
val managementState: StateFlow<ModelManagementState> = _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<String, ModelInfo>) {
_managementState.value = Deletion.Confirming(models)
}
/**
* Delete multiple models one by one while updating UI states with realtime progress
*/
fun deleteModels(modelsToDelete: Map<String, ModelInfo>) = 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<HuggingFaceModel>) : 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<String, ModelInfo>): ModelManagementState()
data class Deleting(val progress: Float = 0f, val models: Map<String, ModelInfo>) : ModelManagementState()
data class Success(val models: List<ModelInfo>) : Deletion()
data class Error(val message: String) : Deletion()
}
}

View File

@ -1,14 +1,5 @@
package com.example.llama.viewmodel 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.TextFieldState
import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.clearText
import androidx.compose.runtime.snapshotFlow 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.filterBy
import com.example.llama.data.model.queryBy import com.example.llama.data.model.queryBy
import com.example.llama.data.model.sortByOrder import com.example.llama.data.model.sortByOrder
import com.example.llama.data.repo.InsufficientStorageException
import com.example.llama.data.repo.ModelRepository import com.example.llama.data.repo.ModelRepository
import com.example.llama.data.source.remote.HuggingFaceDownloadInfo
import com.example.llama.data.source.remote.HuggingFaceModel
import com.example.llama.engine.InferenceService import com.example.llama.engine.InferenceService
import com.example.llama.monitoring.PerformanceMonitor 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 com.example.llama.viewmodel.PreselectedModelToRun.RamWarning
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -48,18 +27,12 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.FileNotFoundException
import java.io.IOException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
@HiltViewModel @HiltViewModel
class ModelsViewModel @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 performanceMonitor: PerformanceMonitor,
private val inferenceService: InferenceService, private val inferenceService: InferenceService,
@ -185,65 +158,6 @@ class ModelsViewModel @Inject constructor(
initialValue = null 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()
fun toggleImportMenu(show: Boolean) {
_showImportModelMenu.value = show
}
// HuggingFace: ongoing query jobs
private var huggingFaceQueryJob: Job? = null
// HuggingFace: Ongoing download jobs
private val activeDownloads = mutableMapOf<Long, HuggingFaceModel>()
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>(ModelManagementState.Idle)
val managementState: StateFlow<ModelManagementState> = _managementState.asStateFlow()
fun resetManagementState() {
huggingFaceQueryJob?.let {
if (it.isActive) { it.cancel() }
}
_managementState.value = ModelManagementState.Idle
}
init { init {
viewModelScope.launch { viewModelScope.launch {
@ -272,9 +186,6 @@ class ModelsViewModel @Inject constructor(
_queryResults.value = it _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 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<String, ModelInfo>) {
_managementState.value = Deletion.Confirming(models)
}
/**
* Delete multiple models one by one while updating UI states with realtime progress
*/
fun deleteModels(models: Map<String, ModelInfo>) = 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 { companion object {
private val TAG = ModelsViewModel::class.java.simpleName 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 SUBSCRIPTION_TIMEOUT_MS = 5000L
private const val QUERY_DEBOUNCE_TIMEOUT_MS = 500L 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 private const val RAM_LOAD_MODEL_BUFFER_BYTES = 300 * 1024
} }
} }
@ -552,29 +274,3 @@ data class PreselectedModelToRun(
val showing: Boolean, 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<HuggingFaceModel>) : 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<String, ModelInfo>): ModelManagementState()
data class Deleting(val progress: Float = 0f, val models: Map<String, ModelInfo>) : ModelManagementState()
data class Success(val models: List<ModelInfo>) : Deletion()
data class Error(val message: String) : Deletion()
}
}