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 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@ -29,11 +33,14 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -41,11 +48,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.bollwerk.shared.model.ResourceDto import de.bollwerk.shared.model.ResourceDto
import java.io.File
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
@ -54,6 +64,8 @@ internal fun ResourceListScreen(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var isSortMenuExpanded by remember { mutableStateOf(false) } var isSortMenuExpanded by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val filteredResources = remember( val filteredResources = remember(
uiState.resources, 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@ -90,7 +121,8 @@ internal fun ResourceListScreen(
} }
} }
) )
} },
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
@ -182,7 +214,19 @@ internal fun ResourceListScreen(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(filteredResources, key = { it.guid }) { resource -> 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) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun ResourceCard(resource: ResourceDto) { private fun ResourceCard(
resource: ResourceDto,
downloadState: DownloadState,
onDownloadClick: () -> Unit
) {
Card( Card(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
@ -209,13 +257,23 @@ private fun ResourceCard(resource: ResourceDto) {
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
IconButton(onClick = { /* download handled externally */ }) { IconButton(
onClick = onDownloadClick,
enabled = downloadState !is DownloadState.Loading
) {
if (downloadState is DownloadState.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Icon( Icon(
imageVector = Icons.Filled.Download, imageVector = Icons.Filled.Download,
contentDescription = "Herunterladen" contentDescription = "Herunterladen"
) )
} }
} }
}
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@ -283,3 +341,21 @@ private fun formatFileSize(bytes: Long): String {
String.format("%.0f KB", bytes / 1024.0) 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 package de.bollwerk.app.ui.resources
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.bollwerk.app.domain.repository.ResourceRepository import de.bollwerk.app.domain.repository.ResourceRepository
import de.bollwerk.shared.model.ResourceDto import de.bollwerk.shared.model.ResourceDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import javax.inject.Inject import javax.inject.Inject
internal enum class SortMode(val label: String) { internal enum class SortMode(val label: String) {
@ -18,13 +22,21 @@ internal enum class SortMode(val label: String) {
SIZE_DESC("Größe groß→klein") 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( internal data class ResourceListUiState(
val resources: List<ResourceDto> = emptyList(), val resources: List<ResourceDto> = emptyList(),
val searchQuery: String = "", val searchQuery: String = "",
val selectedTags: Set<String> = emptySet(), val selectedTags: Set<String> = emptySet(),
val sortMode: SortMode = SortMode.TITLE_ASC, val sortMode: SortMode = SortMode.TITLE_ASC,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val allTags: List<String> = emptyList() val allTags: List<String> = emptyList(),
val downloadStates: Map<String, DownloadState> = emptyMap()
) )
@HiltViewModel @HiltViewModel
@ -74,4 +86,35 @@ internal class ResourceListViewModel @Inject constructor(
fun setSortMode(mode: SortMode) { fun setSortMode(mode: SortMode) {
_uiState.update { it.copy(sortMode = mode) } _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 <cache-path
name="update" name="update"
path="update/" /> path="update/" />
<cache-path
name="downloads"
path="." />
</paths> </paths>