feat(app): implement resource download + 'open with' dialog

Closes #123
This commit is contained in:
Jens Reinemann 2026-05-18 22:19:23 +02:00
parent 44476b21e6
commit 2b33f930d0
3 changed files with 131 additions and 9 deletions

View file

@ -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")
}
}

View file

@ -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<ResourceDto> = emptyList(),
val searchQuery: String = "",
val selectedTags: Set<String> = emptySet(),
val sortMode: SortMode = SortMode.TITLE_ASC,
val isLoading: Boolean = false,
val allTags: List<String> = emptyList()
val allTags: List<String> = emptyList(),
val downloadStates: Map<String, DownloadState> = 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))
}
}
}

View file

@ -9,4 +9,7 @@
<cache-path
name="update"
path="update/" />
<cache-path
name="downloads"
path="." />
</paths>