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" }