From 2b33f930d0105399ed1861259223daeafbe2bea4 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Mon, 18 May 2026 22:19:23 +0200 Subject: [PATCH] feat(app): implement resource download + 'open with' dialog Closes #123 --- .../app/ui/resources/ResourceListScreen.kt | 92 +++++++++++++++++-- .../app/ui/resources/ResourceListViewModel.kt | 45 ++++++++- app/src/main/res/xml/file_paths.xml | 3 + 3 files changed, 131 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt index b64f846..0684f83 100644 --- a/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt +++ b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt @@ -1,5 +1,8 @@ package de.bollwerk.app.ui.resources +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -11,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -29,11 +33,14 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,11 +48,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.bollwerk.shared.model.ResourceDto +import java.io.File @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -54,6 +64,8 @@ internal fun ResourceListScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var isSortMenuExpanded by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current val filteredResources = remember( uiState.resources, @@ -77,6 +89,25 @@ internal fun ResourceListScreen( } } + // Handle download state changes (open file on success, show error snackbar) + uiState.downloadStates.forEach { (guid, state) -> + when (state) { + is DownloadState.Success -> { + LaunchedEffect(guid, state) { + openFile(context, state.file, state.mimeType, snackbarHostState) + viewModel.clearDownloadState(guid) + } + } + is DownloadState.Error -> { + LaunchedEffect(guid, state) { + snackbarHostState.showSnackbar("Download fehlgeschlagen") + viewModel.clearDownloadState(guid) + } + } + else -> {} + } + } + Scaffold( topBar = { TopAppBar( @@ -90,7 +121,8 @@ internal fun ResourceListScreen( } } ) - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> Column( modifier = Modifier @@ -182,7 +214,19 @@ internal fun ResourceListScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(filteredResources, key = { it.guid }) { resource -> - ResourceCard(resource = resource) + val downloadState = uiState.downloadStates[resource.guid] ?: DownloadState.Idle + ResourceCard( + resource = resource, + downloadState = downloadState, + onDownloadClick = { + viewModel.downloadAndOpen( + guid = resource.guid, + mimeType = resource.mimeType, + fileFormat = resource.fileFormat, + context = context + ) + } + ) } } } @@ -193,7 +237,11 @@ internal fun ResourceListScreen( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun ResourceCard(resource: ResourceDto) { +private fun ResourceCard( + resource: ResourceDto, + downloadState: DownloadState, + onDownloadClick: () -> Unit +) { Card( modifier = Modifier.fillMaxWidth() ) { @@ -209,11 +257,21 @@ private fun ResourceCard(resource: ResourceDto) { fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f) ) - IconButton(onClick = { /* download handled externally */ }) { - Icon( - imageVector = Icons.Filled.Download, - contentDescription = "Herunterladen" - ) + IconButton( + onClick = onDownloadClick, + enabled = downloadState !is DownloadState.Loading + ) { + if (downloadState is DownloadState.Loading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Filled.Download, + contentDescription = "Herunterladen" + ) + } } } @@ -283,3 +341,21 @@ private fun formatFileSize(bytes: Long): String { String.format("%.0f KB", bytes / 1024.0) } } + +private suspend fun openFile( + context: Context, + file: File, + mimeType: String, + snackbarHostState: SnackbarHostState +) { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, mimeType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + try { + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + snackbarHostState.showSnackbar("Keine App zum Öffnen dieser Datei gefunden") + } +} diff --git a/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListViewModel.kt b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListViewModel.kt index 6a45427..a238bd8 100644 --- a/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListViewModel.kt +++ b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListViewModel.kt @@ -1,15 +1,19 @@ package de.bollwerk.app.ui.resources +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.bollwerk.app.domain.repository.ResourceRepository import de.bollwerk.shared.model.ResourceDto +import kotlinx.coroutines.Dispatchers 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 kotlinx.coroutines.withContext +import java.io.File import javax.inject.Inject internal enum class SortMode(val label: String) { @@ -18,13 +22,21 @@ internal enum class SortMode(val label: String) { SIZE_DESC("Größe groß→klein") } +internal sealed interface DownloadState { + data object Idle : DownloadState + data object Loading : DownloadState + data class Success(val file: File, val mimeType: String) : DownloadState + data class Error(val message: String) : DownloadState +} + internal data class ResourceListUiState( val resources: List = emptyList(), val searchQuery: String = "", val selectedTags: Set = emptySet(), val sortMode: SortMode = SortMode.TITLE_ASC, val isLoading: Boolean = false, - val allTags: List = emptyList() + val allTags: List = emptyList(), + val downloadStates: Map = emptyMap() ) @HiltViewModel @@ -74,4 +86,35 @@ internal class ResourceListViewModel @Inject constructor( fun setSortMode(mode: SortMode) { _uiState.update { it.copy(sortMode = mode) } } + + fun downloadAndOpen(guid: String, mimeType: String, fileFormat: String, context: Context) { + viewModelScope.launch { + _uiState.update { state -> + state.copy(downloadStates = state.downloadStates + (guid to DownloadState.Loading)) + } + try { + val bytes = resourceRepository.downloadResource(guid) + val file = withContext(Dispatchers.IO) { + File(context.cacheDir, "$guid.$fileFormat").also { it.writeBytes(bytes) } + } + _uiState.update { state -> + state.copy( + downloadStates = state.downloadStates + (guid to DownloadState.Success(file, mimeType)) + ) + } + } catch (e: Exception) { + _uiState.update { state -> + state.copy( + downloadStates = state.downloadStates + (guid to DownloadState.Error(e.message ?: "Unbekannter Fehler")) + ) + } + } + } + } + + fun clearDownloadState(guid: String) { + _uiState.update { state -> + state.copy(downloadStates = state.downloadStates + (guid to DownloadState.Idle)) + } + } } diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 54f70e0..cbd2c96 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -9,4 +9,7 @@ +