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
|
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,11 +257,21 @@ 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(
|
||||||
Icon(
|
onClick = onDownloadClick,
|
||||||
imageVector = Icons.Filled.Download,
|
enabled = downloadState !is DownloadState.Loading
|
||||||
contentDescription = "Herunterladen"
|
) {
|
||||||
)
|
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)
|
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
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,7 @@
|
||||||
<cache-path
|
<cache-path
|
||||||
name="update"
|
name="update"
|
||||||
path="update/" />
|
path="update/" />
|
||||||
|
<cache-path
|
||||||
|
name="downloads"
|
||||||
|
path="." />
|
||||||
</paths>
|
</paths>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue