feat(app): implement resource download + 'open with' dialog
Closes #123
This commit is contained in:
parent
44476b21e6
commit
2b33f930d0
3 changed files with 131 additions and 9 deletions
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,4 +9,7 @@
|
|||
<cache-path
|
||||
name="update"
|
||||
path="update/" />
|
||||
<cache-path
|
||||
name="downloads"
|
||||
path="." />
|
||||
</paths>
|
||||
|
|
|
|||
Loading…
Reference in a new issue