feat(settings): add JSON/Markdown export via Share Intent
Implement export functionality in the Settings screen allowing users to share their inventory data as JSON (via FileProvider + ACTION_SEND with EXTRA_STREAM) or Markdown (via ACTION_SEND with EXTRA_TEXT). Key changes: - ShareContent sealed interface for export events (Json with URI, Markdown with text) - SettingsViewModel: exportJson() writes to cache file and creates FileProvider URI; exportMarkdown() provides text directly - SettingsUiState: isExporting, shareContent, exportError fields - SettingsScreen: LaunchedEffect consumes share events and opens Android Share Sheet via Intent.createChooser - FileProvider registered in AndroidManifest with cache-path config - MockK added as test dependency for FileProvider static mocking - 8 new unit tests covering export success, failure, and state cleanup Closes #37
This commit is contained in:
parent
12f787a406
commit
8193445939
9 changed files with 295 additions and 20 deletions
|
|
@ -73,6 +73,7 @@ dependencies {
|
|||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.mockk)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.room.testing)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,16 @@
|
|||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package de.krisenvorrat.app.ui.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -20,8 +21,8 @@ import androidx.compose.material3.OutlinedTextField
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
|
|
@ -37,6 +38,34 @@ internal fun SettingsScreen(
|
|||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(uiState.shareContent) {
|
||||
val content = uiState.shareContent ?: return@LaunchedEffect
|
||||
val shareIntent = when (content) {
|
||||
is ShareContent.Json -> {
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/json"
|
||||
putExtra(Intent.EXTRA_STREAM, content.fileUri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
is ShareContent.Markdown -> {
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, content.text)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "Krisenvorrat Inventar")
|
||||
}
|
||||
}
|
||||
}
|
||||
context.startActivity(Intent.createChooser(shareIntent, "Exportieren via…"))
|
||||
viewModel.onShareHandled()
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.exportError) {
|
||||
val error = uiState.exportError ?: return@LaunchedEffect
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
viewModel.onExportErrorDismissed()
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TopAppBar(title = { Text("Einstellungen") })
|
||||
|
||||
|
|
@ -112,33 +141,31 @@ internal fun SettingsScreen(
|
|||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Export wird in einem späteren Update verfügbar.",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
},
|
||||
onClick = viewModel::exportJson,
|
||||
enabled = !uiState.isExporting,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Daten exportieren")
|
||||
Text("Daten exportieren (JSON)")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Import wird in einem späteren Update verfügbar.",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
},
|
||||
onClick = viewModel::exportMarkdown,
|
||||
enabled = !uiState.isExporting,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Daten importieren")
|
||||
Text("Als Markdown teilen")
|
||||
}
|
||||
|
||||
if (uiState.isExporting) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,5 +4,8 @@ internal data class SettingsUiState(
|
|||
val householdSize: String = "",
|
||||
val dailyKcalPerPerson: String = "",
|
||||
val isLoading: Boolean = true,
|
||||
val isSaved: Boolean = false
|
||||
val isSaved: Boolean = false,
|
||||
val isExporting: Boolean = false,
|
||||
val shareContent: ShareContent? = null,
|
||||
val exportError: String? = null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
package de.krisenvorrat.app.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.krisenvorrat.app.domain.repository.ImportExportRepository
|
||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -10,11 +14,14 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
internal class SettingsViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val importExportRepository: ImportExportRepository,
|
||||
@ApplicationContext private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
|
|
@ -79,6 +86,47 @@ internal class SettingsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun exportJson() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isExporting = true, exportError = null) }
|
||||
try {
|
||||
val json = importExportRepository.exportToJson()
|
||||
val cacheDir = File(context.cacheDir, "exports")
|
||||
cacheDir.mkdirs()
|
||||
val file = File(cacheDir, "krisenvorrat_export.json")
|
||||
file.writeText(json)
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
_uiState.update { it.copy(isExporting = false, shareContent = ShareContent.Json(uri)) }
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isExporting = false, exportError = "Export fehlgeschlagen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun exportMarkdown() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isExporting = true, exportError = null) }
|
||||
try {
|
||||
val markdown = importExportRepository.exportToMarkdown()
|
||||
_uiState.update { it.copy(isExporting = false, shareContent = ShareContent.Markdown(markdown)) }
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isExporting = false, exportError = "Export fehlgeschlagen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onShareHandled() {
|
||||
_uiState.update { it.copy(shareContent = null) }
|
||||
}
|
||||
|
||||
fun onExportErrorDismissed() {
|
||||
_uiState.update { it.copy(exportError = null) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_HOUSEHOLD_SIZE = "household_size"
|
||||
const val KEY_DAILY_KCAL_PER_PERSON = "daily_kcal_per_person"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package de.krisenvorrat.app.ui.settings
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
internal sealed interface ShareContent {
|
||||
data class Json(val fileUri: Uri) : ShareContent
|
||||
data class Markdown(val text: String) : ShareContent
|
||||
}
|
||||
6
app/src/main/res/xml/file_paths.xml
Normal file
6
app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path
|
||||
name="exports"
|
||||
path="exports/" />
|
||||
</paths>
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
package de.krisenvorrat.app.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.FileProvider
|
||||
import de.krisenvorrat.app.data.db.entity.SettingsEntity
|
||||
import de.krisenvorrat.app.domain.repository.ImportExportRepository
|
||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -12,33 +15,53 @@ import kotlinx.coroutines.test.advanceUntilIdle
|
|||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SettingsViewModelTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private lateinit var fakeSettingsRepository: FakeSettingsRepository
|
||||
private lateinit var fakeImportExportRepository: FakeImportExportRepository
|
||||
private lateinit var mockContext: Context
|
||||
private lateinit var tempDir: File
|
||||
private lateinit var viewModel: SettingsViewModel
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
fakeSettingsRepository = FakeSettingsRepository()
|
||||
fakeImportExportRepository = FakeImportExportRepository()
|
||||
tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports")
|
||||
tempDir.mkdirs()
|
||||
mockContext = mockk(relaxed = true) {
|
||||
every { cacheDir } returns tempDir
|
||||
every { packageName } returns "de.krisenvorrat.app"
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun createViewModel() = SettingsViewModel(
|
||||
settingsRepository = fakeSettingsRepository
|
||||
settingsRepository = fakeSettingsRepository,
|
||||
importExportRepository = fakeImportExportRepository,
|
||||
context = mockContext
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
@ -156,6 +179,132 @@ class SettingsViewModelTest {
|
|||
// Then – zero is invalid, not persisted
|
||||
assertFalse(fakeSettingsRepository.store.containsKey(SettingsViewModel.KEY_HOUSEHOLD_SIZE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_exportMarkdown_success_emitsShareContent() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeImportExportRepository.markdownResult = "# Krisenvorrat Inventar\n"
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.exportMarkdown()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertFalse(state.isExporting)
|
||||
assertNotNull(state.shareContent)
|
||||
assertTrue(state.shareContent is ShareContent.Markdown)
|
||||
assertEquals("# Krisenvorrat Inventar\n", (state.shareContent as ShareContent.Markdown).text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_exportMarkdown_emptyDatabase_emitsMarkdownWithHint() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeImportExportRepository.markdownResult = "# Krisenvorrat Inventar\n\nKeine Artikel vorhanden\n"
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.exportMarkdown()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val content = viewModel.uiState.value.shareContent as ShareContent.Markdown
|
||||
assertTrue(content.text.contains("Keine Artikel vorhanden"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_exportJson_success_emitsShareContentWithUri() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeImportExportRepository.jsonResult = """{"categories":[],"locations":[],"items":[],"settings":[]}"""
|
||||
mockkStatic(FileProvider::class)
|
||||
val mockUri = mockk<android.net.Uri>()
|
||||
every { FileProvider.getUriForFile(any(), any(), any()) } returns mockUri
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.exportJson()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertFalse(state.isExporting)
|
||||
assertNotNull(state.shareContent)
|
||||
assertTrue(state.shareContent is ShareContent.Json)
|
||||
assertEquals(mockUri, (state.shareContent as ShareContent.Json).fileUri)
|
||||
unmockkStatic(FileProvider::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_exportJson_failure_setsExportError() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeImportExportRepository.shouldThrow = true
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.exportJson()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertFalse(state.isExporting)
|
||||
assertNull(state.shareContent)
|
||||
assertEquals("Export fehlgeschlagen", state.exportError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_exportMarkdown_failure_setsExportError() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeImportExportRepository.shouldThrow = true
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.exportMarkdown()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertFalse(state.isExporting)
|
||||
assertNull(state.shareContent)
|
||||
assertEquals("Export fehlgeschlagen", state.exportError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_onShareHandled_clearsShareContent() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeImportExportRepository.markdownResult = "# Test"
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.exportMarkdown()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onShareHandled()
|
||||
|
||||
// Then
|
||||
assertNull(viewModel.uiState.value.shareContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_onExportErrorDismissed_clearsError() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeImportExportRepository.shouldThrow = true
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.exportJson()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onExportErrorDismissed()
|
||||
|
||||
// Then
|
||||
assertNull(viewModel.uiState.value.exportError)
|
||||
}
|
||||
}
|
||||
|
||||
// region Test Helpers
|
||||
|
|
@ -177,4 +326,25 @@ private class FakeSettingsRepository : SettingsRepository {
|
|||
override fun getAll(): Flow<List<SettingsEntity>> = allFlow
|
||||
}
|
||||
|
||||
private class FakeImportExportRepository : ImportExportRepository {
|
||||
var jsonResult = """{"version":1,"categories":[],"locations":[],"items":[],"settings":[]}"""
|
||||
var markdownResult = "# Krisenvorrat Inventar\n"
|
||||
var shouldThrow = false
|
||||
|
||||
override suspend fun exportToJson(): String {
|
||||
if (shouldThrow) throw RuntimeException("Test error")
|
||||
return jsonResult
|
||||
}
|
||||
|
||||
override suspend fun importFromJson(json: String): Result<Unit> {
|
||||
if (shouldThrow) return Result.failure(RuntimeException("Test error"))
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun exportToMarkdown(): String {
|
||||
if (shouldThrow) throw RuntimeException("Test error")
|
||||
return markdownResult
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ room = "2.6.1"
|
|||
navigationCompose = "2.8.5"
|
||||
kotlinxSerialization = "1.7.3"
|
||||
kotlinxCoroutines = "1.9.0"
|
||||
mockk = "1.13.13"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
|
|
@ -43,6 +44,7 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
|
|||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue