From 388532c946362c42598d15d0a441a18f582abad7 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Thu, 14 May 2026 00:09:07 +0200 Subject: [PATCH] feat(export): add ImportExportRepository with JSON export/import domain/repository/ImportExportRepository.kt: new interface with exportToJson() and importFromJson(json) suspend functions. data/export/ExportData.kt: serializable data class bundling all entity lists for JSON serialization via kotlinx.serialization. data/export/ImportExportRepositoryImpl.kt: implementation using kotlinx.serialization + Dispatchers.IO; exportToJson fetches all DAOs and serializes, importFromJson deserializes and upserts back. data/db/dao/{Category,Item,Location,Settings}Dao.kt: added @Upsert upsertAll() suspend function to each DAO to support bulk import. di/RepositoryModule.kt: bound ImportExportRepositoryImpl to ImportExportRepository via Hilt @Binds. test/.../FakeXxxDao.kt: upsertAll() implemented in all four fake DAOs for unit test coverage. --- .../app/data/db/dao/CategoryDao.kt | 4 + .../krisenvorrat/app/data/db/dao/ItemDao.kt | 4 + .../app/data/db/dao/LocationDao.kt | 4 + .../app/data/db/dao/SettingsDao.kt | 3 + .../app/data/export/ExportData.kt | 46 +++++++++++ .../data/export/ImportExportRepositoryImpl.kt | 80 +++++++++++++++++++ .../krisenvorrat/app/di/RepositoryModule.kt | 6 ++ .../repository/ImportExportRepository.kt | 6 ++ .../repository/CategoryRepositoryImplTest.kt | 8 ++ .../data/repository/ItemRepositoryImplTest.kt | 8 ++ .../repository/LocationRepositoryImplTest.kt | 8 ++ .../repository/SettingsRepositoryImplTest.kt | 5 ++ 12 files changed, 182 insertions(+) create mode 100644 app/src/main/java/de/krisenvorrat/app/data/export/ExportData.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/domain/repository/ImportExportRepository.kt diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/dao/CategoryDao.kt b/app/src/main/java/de/krisenvorrat/app/data/db/dao/CategoryDao.kt index 1a662d7..ca31684 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/db/dao/CategoryDao.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/db/dao/CategoryDao.kt @@ -5,6 +5,7 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Update +import androidx.room.Upsert import de.krisenvorrat.app.data.db.entity.CategoryEntity import kotlinx.coroutines.flow.Flow @@ -25,4 +26,7 @@ internal interface CategoryDao { @Query("SELECT * FROM categories WHERE id = :id") suspend fun getById(id: Int): CategoryEntity? + + @Upsert + suspend fun upsertAll(categories: List) } diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/dao/ItemDao.kt b/app/src/main/java/de/krisenvorrat/app/data/db/dao/ItemDao.kt index 9eb8512..108abd0 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/db/dao/ItemDao.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/db/dao/ItemDao.kt @@ -5,6 +5,7 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Update +import androidx.room.Upsert import de.krisenvorrat.app.data.db.entity.ItemEntity import kotlinx.coroutines.flow.Flow import java.time.LocalDate @@ -35,4 +36,7 @@ internal interface ItemDao { @Query("SELECT * FROM items WHERE expiry_date IS NOT NULL AND expiry_date <= :cutoff") fun getExpiringSoonByCutoff(cutoff: LocalDate): Flow> + + @Upsert + suspend fun upsertAll(items: List) } diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/dao/LocationDao.kt b/app/src/main/java/de/krisenvorrat/app/data/db/dao/LocationDao.kt index 0a83f45..3bba19d 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/db/dao/LocationDao.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/db/dao/LocationDao.kt @@ -5,6 +5,7 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Update +import androidx.room.Upsert import de.krisenvorrat.app.data.db.entity.LocationEntity import kotlinx.coroutines.flow.Flow @@ -25,4 +26,7 @@ internal interface LocationDao { @Query("SELECT * FROM locations WHERE id = :id") suspend fun getById(id: Int): LocationEntity? + + @Upsert + suspend fun upsertAll(locations: List) } diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/dao/SettingsDao.kt b/app/src/main/java/de/krisenvorrat/app/data/db/dao/SettingsDao.kt index 2186733..966386b 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/db/dao/SettingsDao.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/db/dao/SettingsDao.kt @@ -17,4 +17,7 @@ internal interface SettingsDao { @Query("SELECT * FROM settings") fun getAll(): Flow> + + @Upsert + suspend fun upsertAll(settings: List) } diff --git a/app/src/main/java/de/krisenvorrat/app/data/export/ExportData.kt b/app/src/main/java/de/krisenvorrat/app/data/export/ExportData.kt new file mode 100644 index 0000000..8880db0 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/export/ExportData.kt @@ -0,0 +1,46 @@ +package de.krisenvorrat.app.data.export + +import kotlinx.serialization.Serializable + +@Serializable +internal data class ExportData( + val version: Int = 1, + val categories: List, + val locations: List, + val items: List, + val settings: List +) + +@Serializable +internal data class CategoryExport( + val id: Int, + val name: String +) + +@Serializable +internal data class LocationExport( + val id: Int, + val name: String +) + +@Serializable +internal data class ItemExport( + val id: String, + val name: String, + val categoryId: Int, + val quantity: Double, + val unit: String, + val unitPrice: Double, + val kcalPer100g: Int?, + val expiryDate: String?, + val locationId: Int, + val minStock: Double, + val notes: String, + val lastUpdated: Long +) + +@Serializable +internal data class SettingExport( + val key: String, + val value: String +) 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 new file mode 100644 index 0000000..54e4ff5 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt @@ -0,0 +1,80 @@ +package de.krisenvorrat.app.data.export + +import de.krisenvorrat.app.data.db.dao.CategoryDao +import de.krisenvorrat.app.data.db.dao.ItemDao +import de.krisenvorrat.app.data.db.dao.LocationDao +import de.krisenvorrat.app.data.db.dao.SettingsDao +import de.krisenvorrat.app.data.db.entity.CategoryEntity +import de.krisenvorrat.app.data.db.entity.ItemEntity +import de.krisenvorrat.app.data.db.entity.LocationEntity +import de.krisenvorrat.app.data.db.entity.SettingsEntity +import de.krisenvorrat.app.domain.repository.ImportExportRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import java.time.LocalDate +import javax.inject.Inject + +internal class ImportExportRepositoryImpl @Inject constructor( + private val categoryDao: CategoryDao, + private val locationDao: LocationDao, + private val itemDao: ItemDao, + private val settingsDao: SettingsDao +) : ImportExportRepository { + + private val jsonSerializer = Json { prettyPrint = false } + + override suspend fun exportToJson(): 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 exportData = ExportData( + categories = categories.map { CategoryExport(id = it.id, name = it.name) }, + locations = locations.map { LocationExport(id = it.id, name = it.name) }, + items = items.map { item -> + ItemExport( + id = item.id, + name = item.name, + categoryId = item.categoryId, + quantity = item.quantity, + unit = item.unit, + unitPrice = item.unitPrice, + kcalPer100g = item.kcalPer100g, + expiryDate = item.expiryDate?.toString(), + locationId = item.locationId, + minStock = item.minStock, + notes = item.notes, + lastUpdated = item.lastUpdated + ) + }, + settings = settings.map { SettingExport(key = it.key, value = it.value) } + ) + jsonSerializer.encodeToString(ExportData.serializer(), exportData) + } + + override suspend fun importFromJson(json: String) = withContext(Dispatchers.IO) { + val exportData = jsonSerializer.decodeFromString(ExportData.serializer(), json) + categoryDao.upsertAll(exportData.categories.map { CategoryEntity(id = it.id, name = it.name) }) + locationDao.upsertAll(exportData.locations.map { LocationEntity(id = it.id, name = it.name) }) + itemDao.upsertAll(exportData.items.map { item -> + ItemEntity( + id = item.id, + name = item.name, + categoryId = item.categoryId, + quantity = item.quantity, + unit = item.unit, + unitPrice = item.unitPrice, + kcalPer100g = item.kcalPer100g, + expiryDate = item.expiryDate?.let { LocalDate.parse(it) }, + locationId = item.locationId, + minStock = item.minStock, + notes = item.notes, + lastUpdated = item.lastUpdated + ) + }) + settingsDao.upsertAll(exportData.settings.map { SettingsEntity(key = it.key, value = it.value) }) + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/di/RepositoryModule.kt b/app/src/main/java/de/krisenvorrat/app/di/RepositoryModule.kt index 251304c..091a5c4 100644 --- a/app/src/main/java/de/krisenvorrat/app/di/RepositoryModule.kt +++ b/app/src/main/java/de/krisenvorrat/app/di/RepositoryModule.kt @@ -4,11 +4,13 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import de.krisenvorrat.app.data.export.ImportExportRepositoryImpl import de.krisenvorrat.app.data.repository.CategoryRepositoryImpl import de.krisenvorrat.app.data.repository.ItemRepositoryImpl import de.krisenvorrat.app.data.repository.LocationRepositoryImpl import de.krisenvorrat.app.data.repository.SettingsRepositoryImpl import de.krisenvorrat.app.domain.repository.CategoryRepository +import de.krisenvorrat.app.domain.repository.ImportExportRepository import de.krisenvorrat.app.domain.repository.ItemRepository import de.krisenvorrat.app.domain.repository.LocationRepository import de.krisenvorrat.app.domain.repository.SettingsRepository @@ -33,4 +35,8 @@ internal abstract class RepositoryModule { @Binds @Singleton abstract fun bindSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository + + @Binds + @Singleton + abstract fun bindImportExportRepository(impl: ImportExportRepositoryImpl): ImportExportRepository } 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 new file mode 100644 index 0000000..00c6eca --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/domain/repository/ImportExportRepository.kt @@ -0,0 +1,6 @@ +package de.krisenvorrat.app.domain.repository + +internal interface ImportExportRepository { + suspend fun exportToJson(): String + suspend fun importFromJson(json: String) +} diff --git a/app/src/test/java/de/krisenvorrat/app/data/repository/CategoryRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/repository/CategoryRepositoryImplTest.kt index a8c98a9..54e393c 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/repository/CategoryRepositoryImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/repository/CategoryRepositoryImplTest.kt @@ -36,6 +36,14 @@ private class FakeCategoryDao : CategoryDao { override fun getAll(): Flow> = flow override suspend fun getById(id: Int): CategoryEntity? = items.find { it.id == id } + + override suspend fun upsertAll(categories: List) { + categories.forEach { category -> + val idx = items.indexOfFirst { it.id == category.id } + if (idx >= 0) items[idx] = category else items.add(category) + } + flow.value = items.toList() + } } class CategoryRepositoryImplTest { diff --git a/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt index 9b847df..758427c 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt @@ -52,6 +52,14 @@ private class FakeItemDao : ItemDao { override fun getExpiringSoonByCutoff(cutoff: LocalDate): Flow> = expiringSoonFlow + + override suspend fun upsertAll(items: List) { + items.forEach { item -> + val idx = this.items.indexOfFirst { it.id == item.id } + if (idx >= 0) this.items[idx] = item else this.items.add(item) + } + emit() + } } private fun buildItem( diff --git a/app/src/test/java/de/krisenvorrat/app/data/repository/LocationRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/repository/LocationRepositoryImplTest.kt index c7a694d..42fe853 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/repository/LocationRepositoryImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/repository/LocationRepositoryImplTest.kt @@ -36,6 +36,14 @@ private class FakeLocationDao : LocationDao { override fun getAll(): Flow> = flow override suspend fun getById(id: Int): LocationEntity? = items.find { it.id == id } + + override suspend fun upsertAll(locations: List) { + locations.forEach { location -> + val idx = items.indexOfFirst { it.id == location.id } + if (idx >= 0) items[idx] = location else items.add(location) + } + flow.value = items.toList() + } } class LocationRepositoryImplTest { diff --git a/app/src/test/java/de/krisenvorrat/app/data/repository/SettingsRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/repository/SettingsRepositoryImplTest.kt index 60deca2..4e16a60 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/repository/SettingsRepositoryImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/repository/SettingsRepositoryImplTest.kt @@ -23,6 +23,11 @@ private class FakeSettingsDao : SettingsDao { override suspend fun getValue(key: String): String? = store[key] override fun getAll(): Flow> = flow + + override suspend fun upsertAll(settings: List) { + settings.forEach { store[it.key] = it.value } + flow.value = store.map { SettingsEntity(key = it.key, value = it.value) } + } } class SettingsRepositoryImplTest {