From 12f787a4061e635f43e0780867302ce4b6ab8eab Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Thu, 14 May 2026 03:04:43 +0200 Subject: [PATCH] feat(export): add exportToMarkdown() to ImportExportRepository Implement Markdown export for the entire inventory (Issue #36). The method renders categories as headings with items in a table (Name, Menge, Einheit, MHD, Lagerort). Empty categories are skipped. Dates are formatted as dd.MM.yyyy (German), quantities use German decimal format (comma). Settings section shows household_size and kcal_per_day if present. Includes 6 unit tests covering: full export, empty categories, missing expiry date, settings section, fractional quantities, and irrelevant settings omission. Closes #36 --- .../data/export/ImportExportRepositoryImpl.kt | 54 ++++++++ .../repository/ImportExportRepository.kt | 1 + .../export/ImportExportRepositoryImplTest.kt | 125 ++++++++++++++++++ 3 files changed, 180 insertions(+) diff --git a/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt index 509ee8a..e598297 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale import javax.inject.Inject internal class ImportExportRepositoryImpl @Inject constructor( @@ -86,4 +88,56 @@ internal class ImportExportRepositoryImpl @Inject constructor( } } } + + override suspend fun exportToMarkdown(): String = withContext(Dispatchers.IO) { + val categories = categoryDao.getAll().first() + val locations = locationDao.getAll().first() + val items = itemDao.getAll().first() + val settings = settingsDao.getAll().first() + + val locationMap = locations.associate { it.id to it.name } + val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMAN) + + val sb = StringBuilder() + sb.appendLine("# Krisenvorrat Inventar") + sb.appendLine() + + for (category in categories) { + val categoryItems = items.filter { it.categoryId == category.id } + if (categoryItems.isEmpty()) continue + + sb.appendLine("## ${category.name}") + sb.appendLine() + sb.appendLine("| Name | Menge | Einheit | MHD | Lagerort |") + sb.appendLine("|------|-------|---------|-----|----------|") + for (item in categoryItems) { + val quantity = formatQuantity(item.quantity) + val expiryDate = item.expiryDate?.format(dateFormatter) ?: "–" + val location = locationMap[item.locationId] ?: "–" + sb.appendLine("| ${item.name} | $quantity | ${item.unit} | $expiryDate | $location |") + } + sb.appendLine() + } + + val settingsMap = settings.associate { it.key to it.value } + val householdSize = settingsMap["household_size"] + val kcalPerDay = settingsMap["kcal_per_day"] + if (householdSize != null || kcalPerDay != null) { + sb.appendLine("## Einstellungen") + sb.appendLine() + if (householdSize != null) sb.appendLine("- **Haushaltsgröße:** $householdSize Personen") + if (kcalPerDay != null) sb.appendLine("- **kcal/Tag:** $kcalPerDay") + sb.appendLine() + } + + sb.toString().trimEnd() + "\n" + } + + private fun formatQuantity(value: Double): String { + return if (value == value.toLong().toDouble()) { + value.toLong().toString() + } else { + String.format(Locale.GERMAN, "%.1f", value) + } + } } diff --git a/app/src/main/java/de/krisenvorrat/app/domain/repository/ImportExportRepository.kt b/app/src/main/java/de/krisenvorrat/app/domain/repository/ImportExportRepository.kt index 6b442a0..65c9ced 100644 --- a/app/src/main/java/de/krisenvorrat/app/domain/repository/ImportExportRepository.kt +++ b/app/src/main/java/de/krisenvorrat/app/domain/repository/ImportExportRepository.kt @@ -3,4 +3,5 @@ package de.krisenvorrat.app.domain.repository internal interface ImportExportRepository { suspend fun exportToJson(): String suspend fun importFromJson(json: String): Result + suspend fun exportToMarkdown(): String } diff --git a/app/src/test/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImplTest.kt index 4cae161..2af95f8 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImplTest.kt @@ -113,4 +113,129 @@ class ImportExportRepositoryImplTest { assertTrue(result.isFailure) assertTrue(result.exceptionOrNull()?.message?.contains("99") == true) } + + @Test + fun test_exportToMarkdown_withCategoriesAndItems_producesValidMarkdown() = runBlocking { + // Given + val categoryDao = FakeCategoryDao() + val locationDao = FakeLocationDao() + val itemDao = FakeItemDao() + categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Lebensmittel"))) + locationDao.upsertAll(listOf(LocationEntity(id = 1, name = "Keller"))) + itemDao.upsertAll(listOf( + buildItemEntity("item1").copy( + name = "Konserve", + quantity = 5.0, + unit = "Stk", + expiryDate = LocalDate.of(2027, 3, 15), + locationId = 1 + ) + )) + val repository = buildRepository(categoryDao, locationDao, itemDao) + + // When + val markdown = repository.exportToMarkdown() + + // Then + assertTrue(markdown.contains("# Krisenvorrat Inventar")) + assertTrue(markdown.contains("## Lebensmittel")) + assertTrue(markdown.contains("| Konserve | 5 | Stk | 15.03.2027 | Keller |")) + } + + @Test + fun test_exportToMarkdown_withEmptyCategory_skipsCategory() = runBlocking { + // Given + val categoryDao = FakeCategoryDao() + val itemDao = FakeItemDao() + categoryDao.upsertAll(listOf( + CategoryEntity(id = 1, name = "Lebensmittel"), + CategoryEntity(id = 2, name = "Hygiene") + )) + itemDao.upsertAll(listOf(buildItemEntity("item1").copy(categoryId = 1))) + val repository = buildRepository(categoryDao, itemDao = itemDao) + + // When + val markdown = repository.exportToMarkdown() + + // Then + assertTrue(markdown.contains("## Lebensmittel")) + assertTrue(!markdown.contains("## Hygiene")) + } + + @Test + fun test_exportToMarkdown_withNoExpiryDate_showsDash() = runBlocking { + // Given + val categoryDao = FakeCategoryDao() + val locationDao = FakeLocationDao() + val itemDao = FakeItemDao() + categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Wasser"))) + locationDao.upsertAll(listOf(LocationEntity(id = 1, name = "Keller"))) + itemDao.upsertAll(listOf( + buildItemEntity("item1").copy(name = "Flasche", expiryDate = null) + )) + val repository = buildRepository(categoryDao, locationDao, itemDao) + + // When + val markdown = repository.exportToMarkdown() + + // Then + assertTrue(markdown.contains("| Flasche | 2 | Stk | – | Keller |")) + } + + @Test + fun test_exportToMarkdown_withSettings_includesSettingsSection() = runBlocking { + // Given + val categoryDao = FakeCategoryDao() + val settingsDao = FakeSettingsDao() + categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Test"))) + settingsDao.upsertAll(listOf( + SettingsEntity(key = "household_size", value = "4"), + SettingsEntity(key = "kcal_per_day", value = "2000") + )) + val repository = buildRepository(categoryDao, settingsDao = settingsDao) + + // When + val markdown = repository.exportToMarkdown() + + // Then + assertTrue(markdown.contains("## Einstellungen")) + assertTrue(markdown.contains("**Haushaltsgröße:** 4 Personen")) + assertTrue(markdown.contains("**kcal/Tag:** 2000")) + } + + @Test + fun test_exportToMarkdown_withFractionalQuantity_formatsWithComma() = runBlocking { + // Given + val categoryDao = FakeCategoryDao() + val locationDao = FakeLocationDao() + val itemDao = FakeItemDao() + categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Lebensmittel"))) + locationDao.upsertAll(listOf(LocationEntity(id = 1, name = "Keller"))) + itemDao.upsertAll(listOf( + buildItemEntity("item1").copy(name = "Reis", quantity = 2.5, unit = "kg") + )) + val repository = buildRepository(categoryDao, locationDao, itemDao) + + // When + val markdown = repository.exportToMarkdown() + + // Then + assertTrue(markdown.contains("| Reis | 2,5 | kg |")) + } + + @Test + fun test_exportToMarkdown_withNoSettingsRelevant_omitsSettingsSection() = runBlocking { + // Given + val categoryDao = FakeCategoryDao() + val settingsDao = FakeSettingsDao() + categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Test"))) + settingsDao.upsertAll(listOf(SettingsEntity(key = "theme", value = "dark"))) + val repository = buildRepository(categoryDao, settingsDao = settingsDao) + + // When + val markdown = repository.exportToMarkdown() + + // Then + assertTrue(!markdown.contains("## Einstellungen")) + } }