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.
This commit is contained in:
Jens Reinemann 2026-05-14 00:09:07 +02:00
parent 4aba9f24a4
commit 388532c946
12 changed files with 182 additions and 0 deletions

View file

@ -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<CategoryEntity>)
}

View file

@ -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<List<ItemEntity>>
@Upsert
suspend fun upsertAll(items: List<ItemEntity>)
}

View file

@ -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<LocationEntity>)
}

View file

@ -17,4 +17,7 @@ internal interface SettingsDao {
@Query("SELECT * FROM settings")
fun getAll(): Flow<List<SettingsEntity>>
@Upsert
suspend fun upsertAll(settings: List<SettingsEntity>)
}

View file

@ -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<CategoryExport>,
val locations: List<LocationExport>,
val items: List<ItemExport>,
val settings: List<SettingExport>
)
@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
)

View file

@ -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) })
}
}

View file

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

View file

@ -0,0 +1,6 @@
package de.krisenvorrat.app.domain.repository
internal interface ImportExportRepository {
suspend fun exportToJson(): String
suspend fun importFromJson(json: String)
}

View file

@ -36,6 +36,14 @@ private class FakeCategoryDao : CategoryDao {
override fun getAll(): Flow<List<CategoryEntity>> = flow
override suspend fun getById(id: Int): CategoryEntity? = items.find { it.id == id }
override suspend fun upsertAll(categories: List<CategoryEntity>) {
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 {

View file

@ -52,6 +52,14 @@ private class FakeItemDao : ItemDao {
override fun getExpiringSoonByCutoff(cutoff: LocalDate): Flow<List<ItemEntity>> =
expiringSoonFlow
override suspend fun upsertAll(items: List<ItemEntity>) {
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(

View file

@ -36,6 +36,14 @@ private class FakeLocationDao : LocationDao {
override fun getAll(): Flow<List<LocationEntity>> = flow
override suspend fun getById(id: Int): LocationEntity? = items.find { it.id == id }
override suspend fun upsertAll(locations: List<LocationEntity>) {
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 {

View file

@ -23,6 +23,11 @@ private class FakeSettingsDao : SettingsDao {
override suspend fun getValue(key: String): String? = store[key]
override fun getAll(): Flow<List<SettingsEntity>> = flow
override suspend fun upsertAll(settings: List<SettingsEntity>) {
settings.forEach { store[it.key] = it.value }
flow.value = store.map { SettingsEntity(key = it.key, value = it.value) }
}
}
class SettingsRepositoryImplTest {