From 8193445939c01bbae5ab2f8e56b6f7f087ac1531 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Thu, 14 May 2026 03:26:15 +0200 Subject: [PATCH] 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 --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 10 + .../app/ui/settings/SettingsScreen.kt | 61 +++++-- .../app/ui/settings/SettingsUiState.kt | 5 +- .../app/ui/settings/SettingsViewModel.kt | 50 ++++- .../app/ui/settings/ShareContent.kt | 8 + app/src/main/res/xml/file_paths.xml | 6 + .../app/ui/settings/SettingsViewModelTest.kt | 172 +++++++++++++++++- gradle/libs.versions.toml | 2 + 9 files changed, 295 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/de/krisenvorrat/app/ui/settings/ShareContent.kt create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 208634e..5a335de 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e0c5cfb..8228ffe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,16 @@ + + + + diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt index ba5b605..a31f00d 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt @@ -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) + ) } } } } } + diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt index ec2d828..6e12e27 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt @@ -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 ) diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt index 87ca2d1..7414e80 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt @@ -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" diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/ShareContent.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/ShareContent.kt new file mode 100644 index 0000000..4ba89dc --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/ShareContent.kt @@ -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 +} diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..ccaa566 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt index e6a4cc7..04154af 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt @@ -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() + 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> = 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 { + 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7a54881..ea5381f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }